diff --git a/.github/goldens-constraints.txt b/.github/goldens-constraints.txt index 9d81f28..c3f8815 100644 --- a/.github/goldens-constraints.txt +++ b/.github/goldens-constraints.txt @@ -3,3 +3,6 @@ Pillow==12.3.0 numpy==2.4.6 texture2ddecoder==1.0.6 astc-encoder-py==0.1.12 +fonttools==4.67.0 +freetype-py==2.5.1 +scipy==1.18.1 diff --git a/.github/scripts/smoke_wheel.py b/.github/scripts/smoke_wheel.py index 84aa7a2..354258c 100644 --- a/.github/scripts/smoke_wheel.py +++ b/.github/scripts/smoke_wheel.py @@ -14,7 +14,8 @@ import nnnotes import ournotes_sim -from nnnotes import deckdata, musicdata +from nnnotes import musicdata +from nnnotes.musicdata import deckdata assert Path(nnnotes.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()) deck = musicdata.Deck(workers=1, aptitude=False) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b8bf00..1245c80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: - name: Install (with the deck model and the fonts extra) run: | python -m pip install --upgrade pip - python -m pip install -e ".[test,deck,fonts]" + python -m pip install -e ".[test,deck,fonts,remote]" - name: Lint run: | python -m ruff check . @@ -55,7 +55,7 @@ jobs: - name: Install run: | python -m pip install --upgrade pip - python -m pip install -e ".[test,deck,fonts]" + python -m pip install -e ".[test,deck,fonts,remote]" - name: Type check run: python -m pyright @@ -65,6 +65,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: "24" - uses: actions/setup-python@v7 with: python-version: "3.13" @@ -75,7 +78,7 @@ jobs: - name: Install (pinned) run: | python -m pip install --upgrade pip - python -m pip install -c .github/goldens-constraints.txt -e ".[test]" + python -m pip install -c .github/goldens-constraints.txt -e ".[test,fonts]" - name: Golden records env: GOLDENS_STRICT: "1" diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0f008..43b5f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ All notable changes to this project are listed here. Versions follow [Semantic V over the command line (commands, options, exit statuses and `--json` reports), the configuration keys, the JSON schemas in `nnnotes.schemas` and the documented Python API. Before 1.0 a minor version can change any of them. +## [0.3.0](https://github.com/MetaSekaiLab/nnnotes/compare/v0.2.5...v0.3.0) - 2026-10-19 + +### Compatibility + +- Configure persistent storage with `[paths] depot` and optional `[depot] remote`. Manage it with `depot plan`, `depot run`, `depot verify` and `depot gc`. +- Exported products use `nnnotes.product/1` manifests and shared object references. Browser playback uses ournotes-player 0.2.x. +- Build named sites with `site build --name NAME -o DIR`. Copy selected source bundles with `bundle pull -o DIR`. +- The `deck` extra uses ournotes-sim 0.0.12. + +### Features + +- Report per-object S3 deletion failures during depot collection. +- Keep PNG and font task results distinct across native compression library implementations. +- Export episode documents, shader files and cue-sheet audio through depot tasks and retained file layouts. +- Share cached results when adding remote storage, verify remote-only content, and retain recent results with their dependencies during collection. +- Validate player package compatibility and publish chart metadata and requested language references consistently. +- Ship schemas for shared product root documents and prefab objects, with numeric font and transform contracts. +- Share catalog versions and labels through depot remotes, with verified catalog files fetched on demand. +- Retain census planning results, emitted task descriptions and standalone task runs as depot collection roots. +- Store content, task inputs, results and catalog versions in a persistent depot with optional remote storage, verified collection and bounded source downloads. +- Release recoverable bundle downloads after successful conversion batches under the configured budget and command leases. +- Assemble Live2D, room, spot, live, story and UI products from shared object tables and explicit manifest references. +- Generate localized live and story text products with game or supplied fonts, shared glyph atlases and material records. +- Build named sites through depot product, player read-set, asset and index tasks. +- Retain unselected regional topics, publish immutable product dependencies in order, and restore complete sites from their saved depot layouts. +- Offer Live variant products from chart manifests and select story UI/font languages explicitly. + +### Architecture + +- Site construction uses shared product requests, named depot catalogs and ordered layout publication. Player page collection and catalog model queries have dedicated interfaces. +- Conversion memoization is bounded within each process, with persistent reuse provided by depot task results. +- Product assembly consumes stored class-converter outputs. Font lookup, texel packing and material construction share object-based implementations. + ## [0.2.1](https://github.com/MetaSekaiLab/nnnotes/compare/v0.2.0...v0.2.1) - 2026-10-13 ### Build @@ -195,5 +228,3 @@ schemas in `nnnotes.schemas` and the documented Python API. Before 1.0 a minor v - List MetaMiku as an author ([cc43e13](https://github.com/MetaSekaiLab/nnnotes/commit/cc43e132875206d02d8665b19c06b27255ab0037)) - Point the project links at MetaSekaiLab/nnnotes ([a2b8304](https://github.com/MetaSekaiLab/nnnotes/commit/a2b83040bcf06771028bbfd5e90c10c225bde3e9)) - English package readme; check commits on pull requests only ([e602dd5](https://github.com/MetaSekaiLab/nnnotes/commit/e602dd5ebf6dda0f18da5ace54a02dbb893a796f)) - - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 807a9d9..7bda8be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,7 +32,9 @@ mkdocs build --strict # the documentation site - **Golden records.** `tests/golden/` records the output of each stage version. When a change alters a stage's output, raise that stage's (or converter's) version and rewrite the records with `GOLDENS_UPDATE=1 pytest tests/pipeline/test_goldens.py`, using the library versions of - `.github/goldens-constraints.txt` (`pip install -c .github/goldens-constraints.txt -e ".[test]"`). + `.github/goldens-constraints.txt` (`pip install -c .github/goldens-constraints.txt -e ".[test,fonts]"`). + Node.js runs the synthetic read-set fixture. CI uses Node.js 24 and Python 3.13 for these records. + PNG-dependent records also use the native `Pillow.zlib` identity listed in their library versions. - **Command reference.** `docs/cli.md` is generated from the parser. After changing a command or an option, run `python -m nnnotes.cli.reference docs/cli.md`; a test fails while the file is out of date. diff --git a/README.md b/README.md index 84ce615..84751bd 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,99 @@ -# nnnotes?! +# nnnotes [English](https://github.com/MetaSekaiLab/nnnotes/blob/main/README.md) | [简体中文](https://github.com/MetaSekaiLab/nnnotes/blob/main/README.zh-CN.md) -nnnotes is an offline data toolkit for the game files of BanG Dream! Our Notes. It reads Addressables catalogs, -downloads and decrypts asset bundles, downloads and decodes master data, and exports stories, Live2D models, spots, -shaders, CRI audio and live charts as structured JSON and common file formats. `nnnotes site build` writes the static -site that [ournotes-player](https://github.com/empty-sekai/ournotes-player) plays, and `nnnotes music-data` one JSON -file with every song and chart. The name was inspired by sssekai. +nnnotes turns your BanG Dream! Our Notes game files into structured data, media and static player sites. +It reads Addressables catalogs, bundles, master data and APK resources; exports stories, Live2D models, live +charts, rooms, UI, shaders and audio; and assembles sites for +[ournotes-player](https://github.com/empty-sekai/ournotes-player). -This is an unofficial fan project, not affiliated with the game's developer or operator. The repository contains no -game assets, keys or server addresses: you supply the game files, the keys and the server addresses in your own -configuration, and the exports stay in the local directories you choose. +A persistent **depot** stores converted objects, task results and catalog versions. Exports share these results, +and named sites retain their selected products across builds. A depot can also share work through a directory +or S3-compatible object storage. -**Documentation: ** +**[Documentation](https://metasekailab.github.io/nnnotes/)** · +[Quick start](https://metasekailab.github.io/nnnotes/quickstart/) · +[Commands](https://metasekailab.github.io/nnnotes/cli/) ## Install +Python 3.11 or newer: + ```sh -pip install nnnotes # Python 3.11+ -pip install 'nnnotes[fonts]' # the game's fonts (--fonts game) and the story text of site build -pip install 'nnnotes[deck]' # the deck model's chart statistics of music-data +pip install nnnotes==0.3.0 +pip install 'nnnotes[fonts]==0.3.0' # story and UI fonts +pip install 'nnnotes[remote]==0.3.0' # S3-compatible depot storage +pip install 'nnnotes[deck]==0.3.0' # music-data chart statistics ``` -nnnotes also needs the game's `base.apk`, decoded master data for the commands that read it, and the external tools -[vgmstream](https://vgmstream.org/) and [FFmpeg](https://ffmpeg.org/) for audio and video (Node.js 20+ and a built -ournotes-player for `site build`). +Choose the extras your commands need; they can be combined as `nnnotes[fonts,remote,deck]==0.3.0`. +Audio/video conversion uses [vgmstream](https://vgmstream.org/) and [FFmpeg](https://ffmpeg.org/). +Site builds additionally require Node.js 22+ and a built **ournotes-player 0.2.x** package. +The deck extra installs `ournotes-sim==0.0.12`. + +## First export -## Quick start +Supply your own client files, keys and server addresses through the configuration wizard: ```sh -nnnotes config init --user # asks for each setting (keys without echo) and writes the per-user config file -nnnotes config check # where each setting comes from and whether it is valid, never its value +nnnotes config init --user +nnnotes config check nnnotes master download --latest -o work/master-bin nnnotes master decode work/master-bin -o work/master -nnnotes --master work/master export story 10462 -o out/story_10462 -nnnotes site build -o out/site --all --player path/to/ournotes-player +nnnotes config set paths.master work/master +nnnotes export episode 10462 -o out/episode.json +nnnotes export story 10462 --fonts game -o out/story +``` + +Set `paths.depot` to a persistent directory and `paths.apk` to your game APK. The wizard reads keys without echo; +`config check` reports setting names, origins and validity. Story export requires the `fonts` extra and audio +tools. See the [quick start](https://metasekailab.github.io/nnnotes/quickstart/) for prerequisites and output layout. + +## Build a player site + +```sh +npm install ournotes-player@0.2.0 +nnnotes site build --name stories -o out/site --story 10462 --fonts game --player node_modules/ournotes-player +node node_modules/ournotes-player/scripts/validate-data.mjs out/site +python -m http.server 8000 --directory out/site ``` -Every command prints a short summary, or with `--json` its report as JSON. Scripts set the config file without -questions: `nnnotes config init --user --no-input --set SECTION.KEY=VALUE ...`. - -## Commands - -| Command | What it does | -| --- | --- | -| `config init\|set\|unset\|check\|path` | write, edit and check the config file | -| `servers` | the game API's server list: regions with their CDN and API roots | -| `catalog keys\|browse` | the addressable keys of a region's catalog; a local web page to browse catalogs and bundles | -| `catalog versions\|import\|fetch\|diff` | catalog versions kept in the store, and what changed between two | -| `bundle pull` | fetch and decrypt the bundle closure of keys into the cache | -| `master version\|download\|decode` | the master data version a region serves, its download and its decoding to JSON | -| `export assets` | every object of the selected bundles in common formats, through the content-addressed store | -| `export episode\|story\|live\|live2d\|spot\|room\|shader\|audio\|ui` | one subject: an ADV episode, a story, a live, a Live2D model, a spot, a room, shaders, a cue sheet, the UI library | -| `apk settings\|hca-key` | the player's graphics settings and the CRI HCA keycode of `base.apk` | -| `store plan\|run\|verify` | what `export assets` would run and why, single tasks, the store's check | -| `voices list\|search\|summary\|get` | the character and story voices of the master data | -| `site build` | the static site of ournotes-player: charts, Live2D models and stories, in several regions and languages | -| `music-data` | every song and chart in one JSON file, with the deck model's chart statistics | - -The [command reference](https://metasekailab.github.io/nnnotes/cli/) lists every option. - -## Documentation - -- [Quick start](https://metasekailab.github.io/nnnotes/quickstart/): from an empty config to an exported story. -- How-to guides: [configure nnnotes](https://metasekailab.github.io/nnnotes/configuration/), - [export stories and lives](https://metasekailab.github.io/nnnotes/export/), - [build an ournotes-player site](https://metasekailab.github.io/nnnotes/site/), - [export every asset](https://metasekailab.github.io/nnnotes/assets/), - [the Japanese release](https://metasekailab.github.io/nnnotes/jp/), - [music data](https://metasekailab.github.io/nnnotes/music-data/). -- Reference: [commands](https://metasekailab.github.io/nnnotes/cli/), - [command outputs](https://metasekailab.github.io/nnnotes/commands/), - [JSON schemas](https://metasekailab.github.io/nnnotes/schemas/), - [Python API](https://metasekailab.github.io/nnnotes/api/), - [contracts](https://metasekailab.github.io/nnnotes/contracts/), [stages](https://metasekailab.github.io/nnnotes/stages/), - [views](https://metasekailab.github.io/nnnotes/views/). -- Explanation: [architecture](https://metasekailab.github.io/nnnotes/architecture/), - [determinism](https://metasekailab.github.io/nnnotes/determinism/), - [validation](https://metasekailab.github.io/nnnotes/validation/). - -Exports are deterministic for a given installation: the same input with the same versions of nnnotes, its Python -dependencies (UnityPy, Pillow, numpy, ...) and the external tools gives byte-identical files; JSON is UTF-8 with LF -line endings and non-finite numbers written as `1e999`. Other versions can encode the same content into other bytes -(PNG files written by another Pillow version can differ in their bytes while their pixels are equal). - -## Changelog and versions - -[CHANGELOG.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CHANGELOG.md) lists the changes of every version. -Versions follow [Semantic Versioning](https://semver.org/) over the command line, the configuration keys, the JSON -schemas and the documented Python API. - -## Contributing - -Setup, checks and commit conventions are in -[CONTRIBUTING.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CONTRIBUTING.md). - -## License - -MIT, see [LICENSE](https://github.com/MetaSekaiLab/nnnotes/blob/main/LICENSE). +Open `http://localhost:8000/story/`. Live2D, MotionSync and Spine features require the corresponding browser +runtimes supplied by the embedding page. See the [site guide](https://metasekailab.github.io/nnnotes/site/). +Use `--pair MUSIC_ID:DIFFICULTY` for a chart, `--live2d KEY` for a model, and `--story-languages ja,en` for selected +story languages. Reusing a site name updates selected topics and retains the others. + +## Choose a workflow + +| Task | Commands | Guide | +|---|---|---| +| Configure inputs | `config init`, `config check`, `servers` | [Configuration](https://metasekailab.github.io/nnnotes/configuration/) | +| Inspect or retain catalogs | `catalog keys`, `browse`, `versions`, `import`, `fetch`, `diff` | [Commands](https://metasekailab.github.io/nnnotes/cli/) | +| Copy source bundles | `bundle pull` | [Asset export](https://metasekailab.github.io/nnnotes/assets/) | +| Export a subject | `export episode`, `story`, `live`, `live2d`, `spot`, `room`, `shader`, `audio`, `ui` | [Exports](https://metasekailab.github.io/nnnotes/export/) | +| Convert selected assets | `export assets`, `depot plan`, `depot run` | [Asset export](https://metasekailab.github.io/nnnotes/assets/) | +| Check and retain stored work | `depot verify`, `depot gc` | [Depot](https://metasekailab.github.io/nnnotes/depot/) | +| Export song and chart statistics | `music-data` | [Music data](https://metasekailab.github.io/nnnotes/music-data/) | +| Find character voices | `voices list`, `search`, `summary`, `get` | [Commands](https://metasekailab.github.io/nnnotes/cli/) | + +Commands print summaries; `--json` selects a structured report. The reference describes exit statuses and options. + +## Data and compatibility + +Product exports use versioned manifests and shared object records. Read the +[format reference](https://metasekailab.github.io/nnnotes/formats/), +[product reference](https://metasekailab.github.io/nnnotes/products/) and +[schemas](https://metasekailab.github.io/nnnotes/schemas/) to consume them. +Task identities include input contents, options and conversion implementations, including native encoders. +Byte-identical output requires matching inputs and library/tool implementations, including Pillow and its native +compression backend. JSON uses UTF-8 and LF; infinite values use `1e999` or `-1e999`. +[Determinism](https://metasekailab.github.io/nnnotes/determinism/) explains reproducibility across installations. + +Versions cover the CLI, configuration, schemas and documented Python API. Before 1.0, a minor version can change +these interfaces. See [CHANGELOG.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CHANGELOG.md) and the [architecture](https://metasekailab.github.io/nnnotes/architecture/). + +## Contributing and license + +[CONTRIBUTING.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CONTRIBUTING.md) describes development and synthetic tests. nnnotes is licensed under +[MIT](https://github.com/MetaSekaiLab/nnnotes/blob/main/LICENSE). This is an unofficial fan project, unaffiliated with the game's developer or operator. +Game resources, keys and server configuration are supplied by each user and are subject to their owners' rights. diff --git a/README.zh-CN.md b/README.zh-CN.md index 243f5f2..413a3ea 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,96 +1,95 @@ -# nnnotes?! +# nnnotes [English](https://github.com/MetaSekaiLab/nnnotes/blob/main/README.md) | [简体中文](https://github.com/MetaSekaiLab/nnnotes/blob/main/README.zh-CN.md) -nnnotes 是 BanG Dream! Our Notes 游戏文件的离线数据工具包:读取 Addressables catalog,下载并解密资源包,下载并解码 -masterdata,再把剧情、Live2D 模型、据点、着色器、CRI 音频和谱面导出成结构化 JSON 与通用格式文件。 -`nnnotes site build` 写出 [ournotes-player](https://github.com/empty-sekai/ournotes-player) 播放的静态站点, -`nnnotes music-data` 写出包含全部歌曲与谱面的单个 JSON 文件。命名灵感来自 sssekai。 +nnnotes 将你自己的 BanG Dream! Our Notes 游戏文件导出为结构化数据、媒体文件和静态播放器站点。 +它读取 Addressables 目录、资源包、主数据和 APK 资源,导出剧情、Live2D、谱面、房间、UI、着色器与音频, +并为 [ournotes-player](https://github.com/empty-sekai/ournotes-player) 组装站点。 -本项目为非官方爱好者项目,与游戏的开发和运营方无关。仓库不包含任何游戏资源、密钥或服务器地址:游戏文件、密钥和服务器地址 -都由使用者在自己的配置中提供,导出结果只保存在使用者指定的本地目录。 +持久化的 **depot** 保存转换后的对象、任务结果和目录版本。各次导出共享这些结果;命名站点在构建之间保留 +所选内容。depot 也可以通过共享目录或兼容 S3 的对象存储在机器之间复用工作。 -**文档(英文):** +**[文档](https://metasekailab.github.io/nnnotes/)** · +[快速开始](https://metasekailab.github.io/nnnotes/quickstart/) · +[命令参考](https://metasekailab.github.io/nnnotes/cli/) ## 安装 +需要 Python 3.11 或更新版本: + ```sh -pip install nnnotes # Python 3.11+ -pip install 'nnnotes[fonts]' # 游戏字体(--fonts game)与 site build 的剧情文字 -pip install 'nnnotes[deck]' # music-data 的组卡模型谱面统计 +pip install nnnotes==0.3.0 +pip install 'nnnotes[fonts]==0.3.0' # 剧情与 UI 字体 +pip install 'nnnotes[remote]==0.3.0' # 兼容 S3 的 depot 远端 +pip install 'nnnotes[deck]==0.3.0' # music-data 谱面统计 ``` -此外还需要游戏的 `base.apk`;读取 masterdata 的命令需要解码后的 masterdata;音频与视频需要外部工具 -[vgmstream](https://vgmstream.org/) 和 [FFmpeg](https://ffmpeg.org/)(`site build` 另需 Node.js 20+ 与构建好的 -ournotes-player)。 +按需选择附加依赖,也可以组合为 `nnnotes[fonts,remote,deck]==0.3.0`。 +音视频转换需要 [vgmstream](https://vgmstream.org/) 与 [FFmpeg](https://ffmpeg.org/)。 +站点构建另需 Node.js 22+ 和已构建的 **ournotes-player 0.2.x** 包。 +`deck` 附加依赖安装 `ournotes-sim==0.0.12`。 + +## 第一次导出 -## 快速上手 +通过配置向导提供自己的客户端文件、密钥与服务地址: ```sh -nnnotes config init --user # 逐项询问设置(密钥不回显),写入用户配置文件 -nnnotes config check # 每项设置的来源与是否有效,不显示设置值 +nnnotes config init --user +nnnotes config check nnnotes master download --latest -o work/master-bin nnnotes master decode work/master-bin -o work/master -nnnotes --master work/master export story 10462 -o out/story_10462 -nnnotes site build -o out/site --all --player path/to/ournotes-player +nnnotes config set paths.master work/master +nnnotes export episode 10462 -o out/episode.json +nnnotes export story 10462 --fonts game -o out/story +``` + +将 `paths.depot` 设为持久目录,`paths.apk` 设为游戏 APK。向导输入密钥时不回显;`config check` 显示配置项名称、 +来源及有效性。剧情导出需要 `fonts` 附加依赖及音频工具。完整前置条件和输出结构见 +[快速开始](https://metasekailab.github.io/nnnotes/quickstart/)。 + +## 构建播放器站点 + +```sh +npm install ournotes-player@0.2.0 +nnnotes site build --name stories -o out/site --story 10462 --fonts game --player node_modules/ournotes-player +node node_modules/ournotes-player/scripts/validate-data.mjs out/site +python -m http.server 8000 --directory out/site ``` -每个命令输出简短摘要,加 `--json` 时输出 JSON 报告。脚本中可以不经提问写好配置文件: -`nnnotes config init --user --no-input --set 段.键=值 ...`。 - -## 命令 - -| 命令 | 作用 | -| --- | --- | -| `config init\|set\|unset\|check\|path` | 写入、修改和检查配置文件 | -| `servers` | 游戏 API 的服务器列表:各区服及其 CDN 与 API 根地址 | -| `catalog keys\|browse` | 区服 catalog 的资源键;在本地网页中浏览 catalog 与资源包 | -| `catalog versions\|import\|fetch\|diff` | store 中保存的 catalog 版本,以及两个版本之间的变化 | -| `bundle pull` | 下载并解密资源键的依赖闭包到缓存 | -| `master version\|download\|decode` | 区服当前的 masterdata 版本、下载,以及解码为 JSON | -| `export assets` | 经内容寻址的 store,把所选资源包中的每个对象导出为通用格式 | -| `export episode\|story\|live\|live2d\|spot\|room\|shader\|audio\|ui` | 单个对象:ADV 剧情集、完整剧情、谱面、Live2D 模型、据点、房间、着色器、cue sheet、UI 库 | -| `apk settings\|hca-key` | `base.apk` 中的画面设置与 CRI HCA 密钥 | -| `store plan\|run\|verify` | `export assets` 将运行什么及原因、单独运行任务、检查 store | -| `voices list\|search\|summary\|get` | masterdata 中的角色语音与剧情语音 | -| `site build` | ournotes-player 静态站点:谱面、Live2D 模型与剧情,支持多个区服与语言 | -| `music-data` | 全部歌曲与谱面的单个 JSON 文件,附组卡模型的谱面统计 | - -[命令参考](https://metasekailab.github.io/nnnotes/cli/)列出了所有参数。 - -## 文档 - -- [快速上手](https://metasekailab.github.io/nnnotes/quickstart/):从空配置到导出一集剧情。 -- 操作指南:[配置](https://metasekailab.github.io/nnnotes/configuration/)、 - [导出剧情与谱面](https://metasekailab.github.io/nnnotes/export/)、 - [构建 ournotes-player 站点](https://metasekailab.github.io/nnnotes/site/)、 - [导出全部资源](https://metasekailab.github.io/nnnotes/assets/)、 - [日服](https://metasekailab.github.io/nnnotes/jp/)、 - [music-data](https://metasekailab.github.io/nnnotes/music-data/)。 -- 参考:[命令](https://metasekailab.github.io/nnnotes/cli/)、 - [命令输出](https://metasekailab.github.io/nnnotes/commands/)、 - [JSON schema](https://metasekailab.github.io/nnnotes/schemas/)、 - [Python API](https://metasekailab.github.io/nnnotes/api/)、 - [契约](https://metasekailab.github.io/nnnotes/contracts/)、[stage](https://metasekailab.github.io/nnnotes/stages/)、 - [视图](https://metasekailab.github.io/nnnotes/views/)。 -- 原理:[架构](https://metasekailab.github.io/nnnotes/architecture/)、 - [确定性](https://metasekailab.github.io/nnnotes/determinism/)、 - [验证](https://metasekailab.github.io/nnnotes/validation/)。 - -在同一套环境下导出是确定的:同样的输入,在 nnnotes、Python 依赖库(UnityPy、Pillow、numpy 等)和外部工具版本都相同时, -导出文件逐字节相同;JSON 一律 UTF-8、LF 换行,非有限数写作 `1e999`。版本不同时,相同内容可能写成不同的字节(不同版本的 -Pillow 写出的 PNG 像素相同,字节可能不同)。 - -## 版本记录与版本号 - -每个版本的改动见 [CHANGELOG.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CHANGELOG.md)。版本号遵循 -[语义化版本](https://semver.org/lang/zh-CN/),约束的接口是命令行、配置键、JSON schema 和文档中的 Python API。 - -## 参与贡献 - -开发环境、检查与提交规范见 -[CONTRIBUTING.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CONTRIBUTING.md)。 - -## License - -MIT,见 [LICENSE](https://github.com/MetaSekaiLab/nnnotes/blob/main/LICENSE)。 +打开 `http://localhost:8000/story/`。Live2D、MotionSync 和 Spine 功能需要嵌入页面提供对应的浏览器运行时, +具体见[站点指南](https://metasekailab.github.io/nnnotes/site/)。 +用 `--pair MUSIC_ID:DIFFICULTY` 选择谱面,`--live2d KEY` 选择模型,`--story-languages ja,en` 选择剧情语言。 +复用同一站点名会更新本次选中的内容,并保留其他内容。 + +## 选择工作流 + +| 任务 | 命令 | 指南 | +|---|---|---| +| 配置输入 | `config init`、`config check`、`servers` | [配置](https://metasekailab.github.io/nnnotes/configuration/) | +| 查看与保存目录 | `catalog keys`、`browse`、`versions`、`import`、`fetch`、`diff` | [命令](https://metasekailab.github.io/nnnotes/cli/) | +| 复制源资源包 | `bundle pull` | [资源导出](https://metasekailab.github.io/nnnotes/assets/) | +| 导出主题 | `export episode`、`story`、`live`、`live2d`、`spot`、`room`、`shader`、`audio`、`ui` | [导出](https://metasekailab.github.io/nnnotes/export/) | +| 转换指定资源 | `export assets`、`depot plan`、`depot run` | [资源导出](https://metasekailab.github.io/nnnotes/assets/) | +| 检查与保留存储结果 | `depot verify`、`depot gc` | [Depot](https://metasekailab.github.io/nnnotes/depot/) | +| 导出歌曲与谱面统计 | `music-data` | [音乐数据](https://metasekailab.github.io/nnnotes/music-data/) | +| 查找角色语音 | `voices list`、`search`、`summary`、`get` | [命令](https://metasekailab.github.io/nnnotes/cli/) | + +命令默认打印摘要;`--json` 输出结构化报告。退出状态和各项参数见命令参考。 + +## 数据与兼容性 + +产物通过版本化清单和共享对象记录描述。消费这些数据时,请查阅 +[格式](https://metasekailab.github.io/nnnotes/formats/)、[产物](https://metasekailab.github.io/nnnotes/products/)和 +[Schema](https://metasekailab.github.io/nnnotes/schemas/)参考。 +任务标识包括输入内容、选项和转换实现,其中也包括原生编码器。 +字节一致的输出需要相同输入及库和工具实现,其中包括 Pillow 及其原生压缩后端。 +JSON 使用 UTF-8 和 LF,无穷值写为 `1e999` 或 `-1e999`。 +[确定性说明](https://metasekailab.github.io/nnnotes/determinism/)解释不同安装环境中的可复现性。 + +版本覆盖 CLI、配置、Schema 和文档中的 Python API。1.0 之前,次版本可以改变这些接口。 +改动见 [CHANGELOG.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CHANGELOG.md),实现分层见[架构](https://metasekailab.github.io/nnnotes/architecture/)。 + +## 贡献与许可 + +[CONTRIBUTING.md](https://github.com/MetaSekaiLab/nnnotes/blob/main/CONTRIBUTING.md) 说明开发和合成测试流程。nnnotes 采用 [MIT](https://github.com/MetaSekaiLab/nnnotes/blob/main/LICENSE) 许可。 +这是非官方爱好者项目,与游戏开发或运营方无关。游戏资源、密钥和服务配置由各用户自行提供,并受其权利人的权利约束。 diff --git a/docs/api.md b/docs/api.md index 9b04a4d..ecb2d1a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -38,6 +38,5 @@ raises `GameApiError`. Other names are internal and can change in any version. ## Site and schemas -::: nnnotes.site.webmodel ::: nnnotes.schemas diff --git a/docs/architecture.md b/docs/architecture.md index e259fe3..a257b19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,12 +10,12 @@ subpackage of `nnnotes` and imports only the layers below it. |---|---|---| | Command line | `nnnotes.cli` | one module per command group; parses options, reads the configuration, prints summaries and `--json` reports | | Products | `nnnotes.site`, `nnnotes.musicdata` | the ournotes-player site (charts, models, stories, indexes); the music data file and its deck statistics | -| Pipeline | `nnnotes.pipeline` | stages, task descriptions and results, the content-addressed store, plans, layouts, the catalog versions, the views of the master data | +| Pipeline | `nnnotes.pipeline` | stages, task descriptions and results, the content-addressed depot, plans, layouts, the catalog versions, the views of the master data | | Content | `nnnotes.content` | the game's subjects as exports: ADV episodes and stories, lives and scores, spots, rooms, the UI library | -| Unity objects | `nnnotes.unity` | bundle reading, the typetree exporter, the player data, Live2D prefabs, text styles and TextMesh Pro fonts | +| Unity objects | `nnnotes.unity` | bundle reading, object ids, atomic texture/sprite/mesh/glTF/PNG conversions, class converters (`nnnotes.unity.convert`), clip bindings (`nnnotes.unity.bindings`), script type trees (`nnnotes.unity.scripttypes`), player data, text styles, font protocols and texel packing | | File formats | `nnnotes.formats` | formats read from bytes: CRI ACB/AWB and HCA, USM movies, CRI Lips data, shaders, motions | | Sources | `nnnotes.source` | the configuration, the APK, Addressables catalogs and bundles, the game API, master data, languages, `Session` | -| Core | `nnnotes.core` | the one JSON writer and the process caches | +| Core | `nnnotes.core` | the one JSON writer, canonical JSON and its hashes, media types, the process caches and the downloads cache | The order is a contract: the `layers` contract of [import-linter](https://import-linter.readthedocs.io/) in `pyproject.toml` fails the build when a module imports a layer above its own, and `nnnotes.site` and @@ -27,27 +27,36 @@ and print, and the work they run is in the library modules below them. Every setting comes from the configuration ([sources and precedence](configuration.md#sources-and-precedence)). `nnnotes.source.session.Session` turns one configuration into the data sources a command needs: the catalog of a region, its decoded master data and the master data version it serves, the APK, the bundle and master data keys and -the store root. It reads a setting when it is first needed and raises a `ConfigError` naming the setting when it is missing or wrong; +the depot root. It reads a setting when it is first needed and raises a `ConfigError` naming the setting when it is missing or wrong; the command line turns that error into exit status 2. The catalog (`nnnotes.source.catalog.Catalog`) resolves an addressable key to its bundle and the bundle's dependency closure, takes a bundle from the APK when the APK has it and from the CDN otherwise, and keeps fetched and -decrypted bundles in the cache directory. - -## Two ways to export - -- **One subject.** `export episode`, `export story`, `export live`, `export live2d`, `export spot`, `export room`, - `export shader`, `export audio`, `export ui`, `site build` and `music-data` read what one subject needs through the - Session and write their output directory directly ([Export stories and lives](export.md)). -- **Every asset.** `export assets` splits the work into stages ([stages](stages.md)). A task is one stage applied to - one subject (a bundle, a content id, a view); its key covers exactly what its output depends on: the stage and - converter versions, the input contents and the options ([contracts](contracts.md)). Results and their files are - stored once by content hash in the store, and the output directory is laid out from the store. A second run finds - every unchanged result in the store and only lays out what changed; `store plan` says what would run and why - ([asset export](assets.md)). - -Both ways use the same readers below the pipeline layer, so a texture, shader, mesh or audio stream is converted by -one implementation whichever command asks for it. +decrypted bundles in the depot's `downloads/` directory. + +## Product and asset requests + +`export assets` runs stages whose tasks are keyed by their input contents, options, stage versions and converter identities. Results and files are stored in the depot; layouts place their content into output directories. `depot plan` reports which tasks and placements would change. + +`export live`, `export story`, `export live2d`, `export spot`, `export room` and `export ui` request the products of selected subjects through that same pipeline. Stories first produce an episode, then request its referenced products. Shared objects and unchanged products reuse committed tasks. + +Class converters emit object records and binary files. Product assemblers read those stored records through an object table and identify shared dependencies by object id and product manifest. Text products use the same font lookup, material and in-memory atlas functions. A product request contains its complete object and media dependencies before assembly. + +`site build` uses the same product requests, then runs player read-set, asset, document and index tasks. A named catalog retains unselected topics and references immutable products. Publication writes dependencies before entry manifests and indexes. The successful run stores the complete layout so a new output directory can be restored through the depot. + +The site's `Builder` composes product snapshots, catalog indexes and player page files. Product assembly and conversion belong to the pipeline and content layers. `catalog_models` queries model subjects from a source catalog. + +Converters use bounded process-local memoization for repeated reads and computations. Persistent reuse is keyed by depot tasks and their content inputs. Clearing a process memo releases its values; a later task can reuse its committed depot result. + +The coordinator observes successful object batches after worker readers close. Their recoverable bundle downloads +can then be released toward the configured budget under the command's exclusive lease. Preliminary episode +requests retain inputs until the expanded product request is known. Stored conversion results remain available +for assembly and layouts. + +`export episode`, `export shader` and `export audio` request depot tasks and project their results into files. +Episode assembly reads the common object table, shader files reuse converted Shader artifacts, and audio files +reuse `cri.audio` streams. `music-data` uses Session to obtain catalog and master inputs for its tabular export. +Commands record their accessed depot content and results as retention roots; dry runs leave no command record. ## Outputs diff --git a/docs/assets.md b/docs/assets.md index 4e089bf..33c23a5 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -5,29 +5,29 @@ meshes as binary glTF, text assets and fonts as their stored bytes, prefabs, mat class as JSON. It also writes the semantic views of the master data (cards, characters, stamps, …) with the files of the objects they name. The work is split into stages ([stages.md](stages.md)); each stage runs one task per subject (a bundle, a view, …), and a task's result is stored under a key that covers exactly what its output depends on -([contracts.md](contracts.md)). A second run over the same data finds every result in the store and only +([contracts.md](contracts.md)). A second run over the same data finds every result in the depot and only rewrites what changed. ``` nnnotes export assets -o out/assets nnnotes export assets -o out/cards --select group:membercard --views cards -nnnotes store plan --select key:Image/Jacket/ -nnnotes store plan --check && echo "up to date" +nnnotes depot plan --select key:Image/Jacket/ +nnnotes depot plan --check && echo "up to date" ``` ## Commands ``` -nnnotes export assets -o OUT [--layout original[,cas]] [--select group:G|key:PREFIX|bundle:GLOB ...] [--views all|none|a,b] - [--store DIR] [--workers N] [--memory GiB] [--fetch-workers N] [--stored-catalog L|SHA] +nnnotes export assets -o OUT [--layout original[,cas][,product]] [--select group:G|key:PREFIX|bundle:GLOB ...] [--views all|none|a,b] + [--depot DIR] [--workers N] [--memory GiB] [--fetch-workers N] [--stored-catalog L|SHA] [--png-level N] [--flac-level N] [--only-class C,...] [--link auto|clone|hard|copy] [--strict] [--dry-run] [--explain] -nnnotes store plan [the selection and parameters of export] [-o OUT] [--json] [--since RUN] [--check] [--census] +nnnotes depot plan [the selection and parameters of export] [-o OUT] [--json] [--since RUN] [--check] [--census] [--emit-tasks DIR] [--why TASK] -nnnotes store run TASK.json|DIR [...] [--store DIR] [--fetch] [--force] +nnnotes depot run TASK.json|DIR [...] [--depot DIR] [--fetch] [--force] nnnotes catalog versions [--json] | import FILE [--label L] [--apk-catalog FILE] [--resource-version V] | fetch [--label L] | diff OLD NEW [--json] -nnnotes store verify [--quick] [--workers N] +nnnotes depot verify [--quick] [--workers N] ``` ### export assets @@ -37,43 +37,48 @@ nnnotes store verify [--quick] [--workers N] | `-o OUT` | output directory | | `--select SEL` | what to export (repeatable, the union; default everything): `group:G` the bundles of an Addressables group (the stable bundle name up to `_assets_`, else up to its first `_`), `key:PREFIX` the bundles and raw files the catalog keys under `PREFIX` load, `bundle:GLOB` the bundles and raw files whose stable name matches | | `--views` | the semantic views to build ([views.md](views.md)): `all`, `none` or names, comma-separated; default `all` when master data is set, else `none` | -| `--layout` | `original`, `cas` or both (below) | -| `--store DIR` | the store (`[paths] store`; default `/store`) | +| `--layout` | `original`, `cas`, `product`, comma-separated (below) | +| `--depot DIR` | the depot (`[paths] depot`) | | `--workers N` | worker processes (default: the CPUs the process may run on, its affinity mask as `taskset` or a container's cpuset sets it; `0`: every task in this process) | | `--memory GiB` | memory budget (default: 80 % of the physical memory where it can be read, else none) | -| `--fetch-workers N` | parallel downloads of bundles not in the cache (default 8) | -| `--stored-catalog` | a catalog version of the store (`catalog versions`) instead of the current catalog | +| `--fetch-workers N` | parallel downloads of bundles not downloaded yet (default 8) | +| `--stored-catalog` | a catalog version of the depot (`catalog versions`) instead of the current catalog | | `--png-level N` | zlib level 0-9 of the PNG files (default 6); part of the task keys | | `--flac-level N` | FLAC compression level 0-12 of the CRI audio and the movies' audio (default 8; every level decodes to the same samples; 12 writes files 0.2-1.3 % smaller in about five times the encoding time); part of the task keys | -| `--only-class C,...` | export only objects of these Unity classes; part of the task keys | -| `--link` | how the layout files are made from the store (below): `auto` (default; `[export] link`), `clone`, `hard` or `copy` | +| `--only-class C,...` | export only the units whose root object is of these Unity classes (a prefab by `GameObject`, [stages.md](stages.md#units)) | +| `--link` | how the layout files are made from the depot (below): `auto` (default; `[export] link`), `clone`, `hard` or `copy` | | `--strict` | exit 1 when a view has gaps: a required role whose address names no catalog key or no fitting sub-object (empty values and roles that do not apply are not gaps); in `voices`, a voice whose cue sheet has no catalog key or whose decoded sheet lacks its cue, and a story episode without a catalog key | | `--dry-run` | print the plan and stop | -| `--explain` | print every setting of the run first: store, context, selection, pipeline, stages not installed, parameters, workers, memory budget, recycling, layouts | +| `--explain` | print every setting of the run first: depot, context, selection, pipeline, stages not installed, parameters, workers, memory budget, recycling, layouts | A run resolves the context (region, language, catalog version) and indexes the catalog, identifies the selected -bundles (the sha256 of each decrypted bundle, remembered by path, size and modification time; bundles not in the -cache are fetched), takes the census of each bundle, links the scripts and addresses, exports the bundles, runs the +bundles (the sha256 of each decrypted bundle, remembered by path, size and modification time; bundles not +downloaded yet are fetched into the depot's `downloads/`), takes the census of each bundle, links the scripts and +addresses, exports the units (one task per hierarchy or object outside any, [stages.md](stages.md#units)), runs the stages that need those results (atlas sprites, views), writes the layouts and the reports. The selected raw files -(CRI audio and movies) not in the cache are fetched while the bundle stages run. The census also covers -the bundles the selected ones depend on, so script classes and atlas textures in other bundles resolve. - -Tasks run in spawned worker processes that stay alive between tasks (no more of them start than a stage has tasks to -run). The longest tasks (by estimated CPU time) start first; a task starts only while the estimated peak memory of -the running tasks and its own fits the budget, and a task estimated above the budget runs alone. A worker is -replaced after 500 tasks or when its resident memory after a task is above 3 GiB, or above 40 % of its part of the -memory budget (the budget divided by the workers) when that is less, but not below 256 MiB: a worker keeps much of -its largest task's memory, which the budget does not count (`--explain` prints the threshold). The estimates come -from each stage's cost model, scaled per stage by the costs measured in earlier runs of the store -(`/costs/calibration.json`, updated after every export; a task's measured peak counts from its worker's -memory at its start). None of this changes an output: a run with one worker and a run with many write the same store +(CRI audio and movies) not downloaded yet are fetched while the bundle stages run. The census also covers +the bundles the selected ones depend on, so script classes and atlas textures in other bundles resolve, and the +export covers the units of those bundles that the selected ones reference. + +Tasks run in spawned worker processes that stay alive between tasks. The tasks of a stage that read the same +loaded data (a stage's batch, such as one bundle) form one job: one worker runs them one after another and loads the +data once. Every other task is a job alone. No more workers start than a stage has jobs to run. The longest jobs (by +estimated CPU time, the sum over their tasks) start first; a job starts only while the estimated peak memory of the +running jobs and its own (the largest of its tasks') fits the budget, and a job estimated above the budget runs +alone. A worker is replaced after a job once it has run 500 tasks, or when its resident memory at the end of a job +is above 3 GiB, or above 40 % of its part of the memory budget (the budget divided by the workers) when that is +less, but not below 256 MiB: a worker keeps much of its largest task's memory, which the budget does not count +(`--explain` prints the threshold). The estimates come +from each stage's cost model, scaled per stage by the costs measured in earlier runs of the depot +(`/costs/calibration.json`, updated after every export; a task's measured peak counts from its worker's +memory at its start). None of this changes an output: a run with one worker and a run with many write the same depot and the same files. A failed object is reported with its task's result and stays failed until something in its key changes. A task that fails as a whole (an exception, an input that cannot be read, a worker that dies) leaves no result and runs again -next time. +next time; when a worker dies, the tasks of its job after the failed one run in another worker. -### store plan +### depot plan What `export assets` with the same flags would do, without running or fetching anything: every task with its status (`hit`: its result is stored, `run`, `unknown`: it reads the result of a task that would run first, or of a bundle @@ -86,25 +91,32 @@ gives per stage its counts and its first 20 tasks that are not hits; `--json` ha | `--json` | the plan document (`nnnotes.plan/1`) | | `--check` | exit 1 when anything would run or a layout file would change, else 0 | | `--census` | first fetch the bundles without a census and take it, so the plan knows every bundle's classes | -| `--emit-tasks DIR` | write the tasks that can run now as task descriptions `DIR/.json` | +| `--emit-tasks DIR` | write the tasks that can run now as task descriptions `DIR/.json`; retain their layout and stored input dependencies in the depot | | `--why TASK` | one task: its status, reasons, and its key parts beside those of the previous run | Reasons: `new-input`, `input-changed`, `stage-version`, `atom-changed`, `params-changed`, `context-changed`, -`previous-failed`, `result-missing`, `forced`; hits are `unchanged` or `cached` (the result is in the store from +`previous-failed`, `result-missing`, `forced`; hits are `unchanged` or `cached` (the result is in the depot from another run); unknown tasks are `pending` on a task, or on `fetch:` for a bundle whose content is not known yet. See [contracts.md](contracts.md#plan-nnnotesplan1). -### store run +Census planning writes a run record for its completed tasks. Emitted descriptions have a saved task layout whose +input references keep stored inputs reachable during collection. Interrupted pipeline runs also retain completed +task results in their run records. -Runs task descriptions (`nnnotes.task/1`, as `plan --emit-tasks` writes them) into a store: each is checked (its key +### depot run + +Runs task descriptions (`nnnotes.task/1`, as `plan --emit-tasks` writes them) into a depot: each is checked (its key is recomputed, its stage must be installed at its version), its inputs are read by content id through their -locators (the store, the cache, a file, or with `--fetch` the catalog), and its result is stored exactly as `export assets` -would store it. Several tasks run one after another in one process; stages that do not read bundles never load +locators (the depot, its downloads, a file, or with `--fetch` the catalog), and its result is stored exactly as +`export assets` would store it. Several tasks run one after another in one process; stages that do not read bundles never load UnityPy. `--force` runs tasks whose result is stored again (the new result must be identical). +Each attempted task has a run record, selected by `depot.run` and its task id. The JSON report's `runs` array names +these records. Collection applies `--keep` separately to the recorded versions of each task. + ### catalog versions, import, fetch, diff -The catalog versions of a store: `import` a catalog file (with the APK's catalog from `[paths] apk` or +The catalog versions of a depot: `import` a catalog file (with the APK's catalog from `[paths] apk` or `--apk-catalog`), `fetch` the current catalog of the configured region and language, `list` them, and `diff` two of them (labels, version ids or sha256 prefixes): keys added, removed and changed, bundles and raw files by stable name, the keys that reach a changed file, and, when a run over each version has stored its address table, the objects by @@ -112,14 +124,27 @@ stable address (an object keeps its stable address while its object id changes w is labelled by `--label`, else by its resource version, else by the first 12 hex digits of its sha256. `fetch` records the resource version it downloads (none for `main`); `import` takes `--resource-version`, else the JP catalog's source version, else the version in a file name `catalog__.bin` as `fetch` and the -cache name them (none for `main`). `export assets` imports the current catalog when it is not imported yet. +downloads name them (none for `main`). `export assets` imports the current catalog when it is not imported yet. -### store verify +### depot verify Checks that every stored content has the sha256 its name says (`--quick`: sizes only) and that every result names its own key, has the key of its parts and names stored contents of the right size. Exit 1 when a problem is found. `--workers` hashes in parallel (default: the CPUs the process may use; `0`: one at a time). +### depot gc + +Removes what no kept run reaches. The kept runs are, for each selection, the `--keep N` latest (default: every run; +the latest run of a selection is always kept). A kept run reaches the results of its tasks, the contents those +results name (their artifacts, and the inputs their task keys name when the depot holds them) and its layout +manifests with the files they list. Results and contents younger than `--grace SECONDS` (default 86400) also stay; +a recent result keeps its older inputs and artifacts. Other runs, results (with their measured costs) and contents are removed. +`--dry-run` counts what would be removed. A layout made of hard links or clones keeps its files. `--remote` +collects the depot's remote the same way, by the runs on the remote and the same grace period. Remote collection +does not populate the local cache. gc runs only when +no other command uses the depot (it exits with an error otherwise), and commands that start meanwhile wait for it. +The downloads are bounded separately, by `[depot] downloads` ([commands.md](commands.md#downloads)). + ## Layouts A layout places the artifacts of a run as files; it never changes their bytes. With one layout the files are @@ -135,7 +160,8 @@ no longer wanted, never touches other files, and continues an interrupted write - other objects under the same container path: `stem[].`, the Addressables sub-object form (`…/member_full[square].png`); - objects without a container path: `_bundles///~.`; -- secondary artifacts beside the primary one: `..` (`….meta.json`, `….blob.m_Data.moc3`); +- secondary artifacts beside the primary one: `..` (`….blob.m_Data.moc3`), `.` for a + role that is its extension (`….png.json`, the `json` of a texture or sprite); - a container path present in two bundles: `stem@.ext` for each; - names Windows refuses, reserved device names and trailing dots are escaped as `%XX`; paths that coincide ignoring case get `~<8 hex>` and are listed in the layout report; @@ -150,28 +176,34 @@ manifest; the run manifest and the printed summary name the method and why it wa | `--link` | the files | extra space | an edit of a file | |---|---|---|---| -| `clone` | clones (reflinks) of the store objects: Linux FICLONE (Btrfs, XFS, bcachefs, …), macOS APFS `clonefile` | none until a file is written (a quota that counts each file's blocks, such as an XFS project quota, counts them in full) | changes that file only | -| `hard` | hard links to the store objects (same file system) | none | fails, except for the superuser: the store objects are read-only (on Windows a delete fails too) | +| `clone` | clones (reflinks) of the depot objects: Linux FICLONE (Btrfs, XFS, bcachefs, …), macOS APFS `clonefile` | none until a file is written (a quota that counts each file's blocks, such as an XFS project quota, counts them in full) | changes that file only | +| `hard` | hard links to the depot objects (same file system) | none | fails, except for the superuser: the depot objects are read-only (on Windows a delete fails too) | | `copy` | copies | the whole layout again | changes that file only | | `auto` (default) | `clone` where the file system makes clones, else `hard` on the same file system, else `copy` | | | `auto` tries each method once, on a temporary file, before the run starts. An explicit `clone` or `hard` that does not work there is a usage error (exit 2) before anything runs; a file that fails with the chosen method is reported (summary, layout report, exit 1), left out of the manifest and placed by the next run: no file falls back to -another method. The store writes its objects read-only; a layout clears the attribute of a file it owns before it -replaces or removes it where the system needs that (Windows), and the store object stays read-only. +another method. The depot writes its objects read-only; a layout clears the attribute of a file it owns before it +replaces or removes it where the system needs that (Windows), and the depot object stays read-only. `cas` is `assets/.` per distinct content, plus the manifest. Each layout follows from the other: `cas` from the `original` manifest alone, `original` from `cas` and the results. The views are the exception: `original` has them with the paths of their objects, `cas` the view documents they are made from. +`product` places the products of the selection ([products](products.md)): every product stage makes its products +of the catalog keys the selected bundles hold (the Live2D stages: each model key), and the layout writes each +product's manifest (`models/.json`, `cubism/.json`) with its files and the files of its objects at +`assets/.`. The product stages run only with this layout. Without `original` or `cas`, the run exports +only the objects the products reach, not every object of the selection. + ## Reports `OUT/_reports/`: | File | Content | |---|---| -| `run.json` | the run manifest (`nnnotes.run/1`): context, selection, parameters, stage versions, every task with its key and status, the layout manifests, a summary; also stored as `/runs/.json` | +| `run.json` | the run manifest (`nnnotes.run/1`): context, selection, parameters, stage versions, every task with its key and status, the layout manifests, a summary; also stored as `/runs/.json` | | `coverage.json` | per class the census total and the count of every item status (`exported`, `contained`, `generic`, `unsupported`, `failed`, and `missing`: objects no task covered; an object two tasks cover, such as a sprite whose image `sprite.crop` makes, counts once); per reason code its count and five examples | | `failures.json` | failed tasks and failed objects, with their codes and messages | | `sources.json` | files of the selection that could not be read: `source.absent` (a file the catalog names that is not there, or an APK file without `[paths] apk`), `source.error` (the download failed) | @@ -185,28 +217,28 @@ and the reports directory. | Command | 0 | 1 | 2 | 3 | 4 | |---|---|---|---|---|---| | `export assets` | complete (unsupported objects allowed) | a task, an object or a download failed (reports written); with `--strict` also view gaps | usage or settings | aborted: a setting or tool a task needs is missing, or interrupted; results written so far stay valid | | -| `store plan` | | with `--check`: something would run or change | usage or settings | | | -| `store run` | every task ran or hit | a task failed or its result is partial | usage or settings | | a task is incompatible (edited, unknown stage or version); nothing of the batch runs | -| `store verify` | no problem | problems found | usage or settings | | | +| `depot plan` | | with `--check`: something would run or change | usage or settings | | | +| `depot run` | every task ran or hit | a task failed or its result is partial | usage or settings | | a task is incompatible (edited, unknown stage or version); nothing of the batch runs | +| `depot verify` | no problem | problems found | usage or settings | | | ## Running tasks elsewhere -Task descriptions carry everything a task needs; any machine with the same nnnotes version and access to the store +Task descriptions carry everything a task needs; any machine with the same nnnotes version and access to the depot can run them. A task can only be described once the content of its inputs is known, so the bundles are identified first (`plan --census` fetches and hashes them and takes their census). Then plan the tasks that can run now, run them in batches on as many workers as you like, and plan again until nothing is left; `export assets` finally writes the layouts and reports (every task is a hit by then). ```sh -store=/data/nnnotes-store -nnnotes store plan --store "$store" --census > /dev/null +depot=/data/nnnotes-depot +nnnotes depot plan --depot "$depot" --census > /dev/null for round in 1 2 3 4 5 6 7 8; do rm -rf tasks - nnnotes store plan --store "$store" --emit-tasks tasks > /dev/null + nnnotes depot plan --depot "$depot" --emit-tasks tasks > /dev/null ls tasks/*.json > /dev/null 2>&1 || break # nothing left to run - ls tasks/*.json | xargs -n 50 -P 16 nnnotes store run --store "$store" --fetch + ls tasks/*.json | xargs -n 50 -P 16 nnnotes depot run --depot "$depot" --fetch done -nnnotes export assets --store "$store" -o out/assets +nnnotes export assets --depot "$depot" -o out/assets ``` Each round runs the tasks whose inputs are known: the catalog index and the censuses, then the script and address diff --git a/docs/cli.md b/docs/cli.md index 1b38fe7..e7de8bd 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -11,10 +11,9 @@ to standard error. Exit status 2 is a usage or settings error. | `--config` `FILE` | TOML config file (else NNNOTES_CONFIG, else ./nnnotes.toml) | | `--region` `REGION` | region: a [servers.] table ([catalog] region) | | `--language` `LANG` | catalog and client language: ja, en, zh-Hant, zh-Hans or ko ([catalog] language) | -| `--catalog` `FILE` | catalog .bin file ([paths] catalog; else downloaded into the cache) | +| `--catalog` `FILE` | catalog .bin file ([paths] catalog; else downloaded into the depot) | | `--catalog-version` `VERSION` | the resource version of an international catalog ([catalog] version; else the version the game API serves when an API root is configured, otherwise main) | -| `--cache` `DIR` | cache directory ([paths] cache) | -| `--store` `DIR` | store directory ([paths] store; default /store) | +| `--depot` `DIR` | depot directory ([paths] depot) | | `--master` `DIR` | decoded master data directory ([paths] master) | | `--apk` `FILE` | base.apk ([paths] apk) | | `--ffmpeg` `EXE` | ffmpeg executable ([paths] ffmpeg; else on PATH) | @@ -97,7 +96,7 @@ browse the configured regions' catalogs and bundles in a local web page ## nnnotes catalog versions -the catalog versions imported into the store (--json: nnnotes.catalogs/1) +the catalog versions imported into the depot (--json: nnnotes.catalogs/1) ## nnnotes catalog import @@ -112,7 +111,7 @@ import a catalog file (with the APK's catalog when [paths] apk is set) ## nnnotes catalog fetch -fetch the region's current catalog and import it into the store +fetch the region's current catalog and import it into the depot | Argument | Description | | --- | --- | @@ -120,7 +119,7 @@ fetch the region's current catalog and import it into the store ## nnnotes catalog diff -keys, bundles, raw files (and objects) that differ between two catalog versions of the store (--json: nnnotes.catalog-diff/1) +keys, bundles, raw files (and objects) that differ between two catalog versions of the depot (--json: nnnotes.catalog-diff/1) | Argument | Description | | --- | --- | @@ -129,11 +128,12 @@ keys, bundles, raw files (and objects) that differ between two catalog versions ## nnnotes bundle pull -fetch the bundle closure of keys into the cache (prints the cached bundle files) +fetch the bundle closure of keys and copy the decrypted bundles to a directory (prints the bundle files) | Argument | Description | | --- | --- | | `KEY` ... | addressable keys (`catalog keys` lists them) | +| `-o`, `--out` `OUT` | directory the bundles are copied to, under their file names (required) | ## nnnotes master version @@ -164,23 +164,23 @@ Exactly one of `--version`, `--latest`. ## nnnotes export assets -every object of the selected bundles -> common formats (the store and its layouts) +every object of the selected bundles -> common formats (the depot and its layouts) | Argument | Description | | --- | --- | | `-o`, `--out` `OUT` | output directory (required) | | `--select` `SEL` | group:, key: or bundle: (repeatable; default: everything) | | `--views` `all\|none\|a,b` | semantic views to build (default: all when master data is set, else none) | -| `--stored-catalog` `LABEL\|SHA` | a catalog version of the store (`catalog versions`; default: the current catalog) | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | | `--png-level` `N` | PNG compression level 0-9 (changes the output) | | `--flac-level` `N` | FLAC compression level 0-12 of the CRI audio (default 8; every level decodes to the same samples; changes the files) | -| `--only-class` `C,...` | export only objects of these classes | +| `--only-class` `C,...` | export only the units whose root object is of these classes | | `--workers` `N` | worker processes (default: the CPUs this process may use; 0: this process) | | `--memory` `GiB` | memory budget of the running tasks and workers (default: 80 % of the physical memory) | | `--fetch-workers` `N` | parallel downloads (default 8) | -| `--layout` `LAYOUT` | original, cas or original,cas (default original) | +| `--layout` `LAYOUT` | original, cas, product, comma-separated (default original; product: the products of the selection) | | `--explain` | print every setting of the run first | -| `--link` `LINK` | how the layout files are made from the store: auto (default; [export] link): a clone where the file system makes them, else a hard link (read-only), else a copy; or that method only (one of `auto`, `clone`, `hard`, `copy`) | +| `--link` `LINK` | how the layout files are made from the depot: auto (default; [export] link): a clone where the file system makes them, else a hard link (read-only), else a copy; or that method only (one of `auto`, `clone`, `hard`, `copy`) | | `--strict` | exit 1 when a view has gaps | | `--dry-run` | print the plan, run nothing | @@ -195,63 +195,90 @@ an ADV episode -> JSON ## nnnotes export story -an ADV episode -> story directory (episode, audio, scene, UI, media, videos) + its Live2D models +an ADV episode -> the story product and the products it plays with (scene, assets, Live2D models, cue sheets, videos, chat, UI, fonts, CRI Lips) | Argument | Description | | --- | --- | | `adv_id` | MasterAdv id of the episode | +| `--story-languages` `LANG,...` | text languages, comma-separated (default: [catalog] language) | +| `--fonts` `FONTS` | glyph source: open font files (default) or game TMP assets (one of `open`, `game`) | +| `--font` `LANG=PATH` | font file for a language, or emoji=PATH (repeatable; [paths] fonts.) | +| `--format` `FORMAT` | web audio format of the cue sheets (default aac) (one of `aac`, `flac`, `mp3`, `opus`, `vorbis`) | +| `--flac-level` `N` | FLAC compression level 0-12 of the decoded sounds (default 8; every level decodes to the same samples) | | `-o`, `--out` `OUT` | output directory (required) | -| `--models` `DIR` | directory of the Live2D models, one / each (default: OUT/../live2d) | -| `--force` | export the models again whose directory exists | -| `--format` `FORMAT` | audio file format (default flac) (one of `flac`, `ogg`, `wav`) | -| `--flac-level` `N` | FLAC compression level 0-12 of --format flac (default 8; every level decodes to the same samples) | -| `--no-audio` | do not decode the cue sheets | -| `--fonts` `FONTS` | open (default): text layout and style only, no font data; game: also the game's TMP fonts (needs the 'fonts' extra) (one of `open`, `game`) | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | +| `--workers` `N` | worker processes (default: the usable CPUs) | +| `--link` `LINK` | as export assets --link (one of `auto`, `clone`, `hard`, `copy`) | +| `--explain` | print every setting of the run first | +| `--dry-run` | print the plan, run nothing | ## nnnotes export live -a live (music + difficulty) -> self-contained live directory +a live (music + difficulty) -> the live product and the products it plays with (scene, note highway, sounds, music) | Argument | Description | | --- | --- | | `music_id` | MasterLiveMusic id | | `--difficulty` `DIFFICULTY` | chart difficulty (default expert) (one of `easy`, `normal`, `hard`, `expert`) | -| `--format` `FORMAT` | audio file format (default flac) (one of `flac`, `ogg`, `wav`) | -| `--flac-level` `N` | FLAC compression level 0-12 of --format flac (default 8; every level decodes to the same samples) | -| `--fonts` `FONTS` | start canvas text: open (default): layout and style only, no font data (your fonts are not used); game: also the game's TMP fonts (needs the 'fonts' extra) (one of `open`, `game`) | +| `--format` `FORMAT` | web audio format of the BGM (default aac) (one of `aac`, `flac`, `mp3`, `opus`, `vorbis`) | +| `--flac-level` `N` | FLAC compression level 0-12 of the decoded sounds (default 8; every level decodes to the same samples) | | `--band` `BAND` | band of the LightWeight background and start timeline (default: band of the music's first vocal character) | | `--leader-card` `LEADER_CARD` | deck centre MasterMemberCard id (its character's band) | | `--live-option` `OPTION[=VALUES]` | also export the files of a Live option's variants (repeatable): MirrorChart, MeasureLineDisplay, NoteDesignId, NoteEffectId, LiveQuality, NoteSePatternId (every value of the master data, or =v,v...), defaults (the option defaults and ranges only) or all | +| `--live-languages` `CODES` | comma-separated UI languages, or all (default: catalog language) | +| `--fonts` `FONTS` | glyph source: open font files (default) or game TMP assets (one of `open`, `game`) | +| `--font` `LANG=PATH` | font file for a language (repeatable; [paths] fonts.) | | `-o`, `--out` `OUT` | output directory (required) | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | +| `--workers` `N` | worker processes (default: the usable CPUs) | +| `--link` `LINK` | as export assets --link (one of `auto`, `clone`, `hard`, `copy`) | +| `--explain` | print every setting of the run first | +| `--dry-run` | print the plan, run nothing | At most one of `--band`, `--leader-card`. ## nnnotes export live2d -a Live2D model -> runtime model directory +a Live2D model -> its Cubism SDK files, or the player's model | Argument | Description | | --- | --- | | `key` | model key Character/Live2D///model/, or its model id | | `-o`, `--out` `OUT` | output directory (required) | +| `--player` | write the player's model (models/.json and assets/) instead of the Cubism SDK files | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | +| `--workers` `N` | worker processes (default: the usable CPUs) | +| `--link` `LINK` | as export assets --link (one of `auto`, `clone`, `hard`, `copy`) | +| `--explain` | print every setting of the run first | +| `--dry-run` | print the plan, run nothing | ## nnnotes export spot -a spot -> spot.json + Spine + room.glb + shaders +a spot -> spot.json + Spine files + room.glb + room.json | Argument | Description | | --- | --- | | `spot_id` | MasterHomeSpot id | | `-o`, `--out` `OUT` | output directory (required) | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | +| `--workers` `N` | worker processes (default: the usable CPUs) | +| `--link` `LINK` | as export assets --link (one of `auto`, `clone`, `hard`, `copy`) | +| `--explain` | print every setting of the run first | +| `--dry-run` | print the plan, run nothing | ## nnnotes export room -a background prefab -> glb +a spot background -> room.glb + room.json | Argument | Description | | --- | --- | -| `key` | addressable key of the prefab | -| `-o`, `--out` `OUT` | output .glb file (required) | +| `key` | background key Spot//Background/ | +| `-o`, `--out` `OUT` | output directory (required) | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | +| `--workers` `N` | worker processes (default: the usable CPUs) | +| `--link` `LINK` | as export assets --link (one of `auto`, `clone`, `hard`, `copy`) | +| `--explain` | print every setting of the run first | +| `--dry-run` | print the plan, run nothing | ## nnnotes export shader @@ -286,8 +313,11 @@ the APK's UI prefabs -> an offline UI prefab library for ournotes-player/ui | `--key` `KEY` | exact APK catalog key (repeatable) | | `--prefix` `PREFIX` | APK UI key prefix (default EmbUI/) | | `--limit` `LIMIT` | maximum number of keys | -| `--no-dependencies` | omit additional prefab roots and controllers | -| `--force` | re-export the selected keys | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | +| `--workers` `N` | worker processes (default: the usable CPUs) | +| `--link` `LINK` | as export assets --link (one of `auto`, `clone`, `hard`, `copy`) | +| `--explain` | print every setting of the run first | +| `--dry-run` | print the plan, run nothing | ## nnnotes apk settings @@ -305,7 +335,7 @@ find the HCA keycode of the CRI audio (never printed) | --- | --- | | `--write` `DIR` | write it as DIR/.hcakey (the file vgmstream reads) | -## nnnotes store plan +## nnnotes depot plan what `export assets` would run and why, without running it (--json: nnnotes.plan/1) @@ -314,14 +344,14 @@ what `export assets` would run and why, without running it (--json: nnnotes.plan | `-o`, `--out` `OUT` | output directory of the export (counts the layout files to write) | | `--select` `SEL` | group:, key: or bundle: (repeatable; default: everything) | | `--views` `all\|none\|a,b` | semantic views to build (default: all when master data is set, else none) | -| `--stored-catalog` `LABEL\|SHA` | a catalog version of the store (`catalog versions`; default: the current catalog) | +| `--stored-catalog` `LABEL\|SHA` | a catalog version of the depot (`catalog versions`; default: the current catalog) | | `--png-level` `N` | PNG compression level 0-9 (changes the output) | | `--flac-level` `N` | FLAC compression level 0-12 of the CRI audio (default 8; every level decodes to the same samples; changes the files) | -| `--only-class` `C,...` | export only objects of these classes | +| `--only-class` `C,...` | export only the units whose root object is of these classes | | `--workers` `N` | worker processes (default: the CPUs this process may use; 0: this process) | | `--memory` `GiB` | memory budget of the running tasks and workers (default: 80 % of the physical memory) | | `--fetch-workers` `N` | parallel downloads (default 8) | -| `--layout` `LAYOUT` | original, cas or original,cas (default original) | +| `--layout` `LAYOUT` | original, cas, product, comma-separated (default original; product: the products of the selection) | | `--explain` | print every setting of the run first | | `--since` `RUN` | explain against this run (default: the latest of the selection) | | `--check` | exit 1 when anything would run or change | @@ -329,9 +359,9 @@ what `export assets` would run and why, without running it (--json: nnnotes.plan | `--emit-tasks` `DIR` | write the runnable tasks as task descriptions | | `--why` `TASK` | the reasons and key parts of one task | -## nnnotes store run +## nnnotes depot run -run task descriptions (nnnotes.task/1) into the store +run task descriptions (nnnotes.task/1) into the depot | Argument | Description | | --- | --- | @@ -339,7 +369,7 @@ run task descriptions (nnnotes.task/1) into the store | `--fetch` | fetch inputs through their catalog locators | | `--force` | run tasks whose result is stored (it must not change) | -## nnnotes store verify +## nnnotes depot verify check every stored content and result @@ -348,6 +378,17 @@ check every stored content and result | `--quick` | check sizes only, do not hash the contents | | `--workers` `N` | parallel hashing (default the CPUs this process may use; 0: one at a time) | +## nnnotes depot gc + +remove the runs, results and contents no kept run reaches + +| Argument | Description | +| --- | --- | +| `--keep` `N` | keep the N latest runs of each selection (default: every run; the latest run of a selection is kept) | +| `--dry-run` | count what would be removed, remove nothing | +| `--grace` `SECONDS` | keep recent results and their inputs and contents in both depots (default: 86400 seconds) | +| `--remote` | also collect the depot's remote, by its runs (its results and contents younger than a day stay) | + ## nnnotes voices list the voices matching the filters (the text column in the --language language, default [catalog] language) @@ -405,32 +446,32 @@ live charts, Live2D models and stories -> static site for ournotes-player (share | Argument | Description | | --- | --- | | `-o`, `--out` `SITE` | site directory (required) | +| `--name` `NAME` | persistent site name in the depot (default: default) | | `--player` `PLAYER` | ournotes-player checkout (built) or installed package ([paths] player) | | `--pair` `PAIR` | : (repeatable) | | `--all` | every MasterLiveMusic x difficulty | -| `--player-only` | rewrite the player files, charts.json and models.json only | -| `--reingest-json` | store every chart's and model's JSON files again | +| `--player-only` | publish the saved catalog with current player pages | | `--live2d` `MODEL` | Live2D model id or key Character/Live2D///model/ (repeatable) | | `--all-live2d` | every Live2D model of the catalog | | `--format` `FORMAT` | audio format of the charts and stories (default aac) (one of `aac`, `opus`, `vorbis`, `mp3`, `flac`) | | `--no-audio` | export no audio files | | `--compress` `COMPRESS` | encoding of the compressible assets (JSON, shaders, moc3, ...) where it makes them smaller: gzip (default), br (brotli) or none (one of `gzip`, `br`, `none`) | -| `--force` | rebuild charts, models and stories whose manifest exists (stories: also their models) | -| `--tmp` `TMP` | directory for the temporary live and model builds (default .tmp) | -| `--workers` `N` | parallel music / story / model processes (default a quarter of the CPUs, up to 8; models up to 4; 0: this process) | -| `--read-workers` `N` | chart read sets run at a time (default half the CPUs, up to 16) | +| `--workers` `N` | product task worker processes; 0: this process | +| `--memory` `GiB` | product task memory budget | +| `--fetch-workers` `N` | parallel source downloads | +| `--stored-catalog` `LABEL\|SHA` | a stored catalog version of the depot | | `--story` `ADV_ID` | story episode: its MasterAdv id (repeatable) | | `--all-stories` | every story episode (MasterAdv) of the master data | -| `--story-languages` `LANGS` | languages of the stories' text, comma-separated (default: ja,en,zh-Hant,zh-Hans,ko) | +| `--story-languages` `LANGS` | languages of the stories' text, comma-separated (default: the catalog language) | | `--font` `LANG=PATH` | font file the story text of a language is drawn with (repeatable; [paths] fonts.); emoji=PATH: the colour emoji font the emoji sprites are drawn from ([paths] fonts.emoji) | | `--site-region` `REGION` | a region the site serves: a [servers.] table (repeatable; the first is the base; default: [catalog] region) | | `--all-regions` | every configured region | -| `--fonts` `FONTS` | text of the start canvas and the stories: open (default): the start canvas has layout and style only, the stories glyphs from your font files (--font); game: the game's TMP fonts (needs the 'fonts' extra) (one of `open`, `game`) | +| `--fonts` `FONTS` | text of the start canvas and stories: open (default): glyphs from your font files (--font); game: the game's TMP fonts (needs the 'fonts' extra) (one of `open`, `game`) | | `--band` `BAND` | band of the LightWeight background and start timeline (default: band of the music's first vocal character) | | `--leader-card` `LEADER_CARD` | deck centre MasterMemberCard id (its character's band) | | `--live-option` `OPTION[=VALUES]` | also export the files of a Live option's variants (repeatable): MirrorChart, MeasureLineDisplay, NoteDesignId, NoteEffectId, LiveQuality, NoteSePatternId (every value of the master data, or =v,v...), defaults (the option defaults and ranges only) or all | -At most one of `--pair`, `--all`, `--player-only`, `--reingest-json`. +At most one of `--pair`, `--all`, `--player-only`. At most one of `--live2d`, `--all-live2d`. diff --git a/docs/commands.md b/docs/commands.md index a5188e5..6969eef 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,11 +5,11 @@ nnnotes [options] ``` Global options (before or after the command name): `--config`, `--region`, `--language`, `--catalog`, -`--catalog-version`, `--cache`, `--store`, `--master`, `--apk`, `--ffmpeg`, `--vgmstream`, `--node` and `--json`, +`--catalog-version`, `--depot`, `--master`, `--apk`, `--ffmpeg`, `--vgmstream`, `--node` and `--json`, plus `--version` and `--help`. They set the settings described in [configuration.md](configuration.md), which also lists the settings each command needs. `nnnotes --help` prints the options of a command; -[cli.md](cli.md) lists every command with its options. The asset export commands (`export assets`, `store plan`, -`store run`, `store verify` and the catalog versions of the store) are described in [assets.md](assets.md). +[cli.md](cli.md) lists every command with its options. The asset export commands (`export assets`, `depot plan`, +`depot run`, `depot verify` and the catalog versions of the depot) are described in [assets.md](assets.md). Commands that write files take the output path with `-o/--out` (required). A command prints a short summary on standard output; with `--json` it prints its report as JSON (UTF-8). Every JSON file is written by one @@ -38,26 +38,53 @@ Write the config file from the template (asking in a terminal, else from `--set` check every setting's origin and format, and list where the config file is looked up. No `config` command prints a setting's value. Details in [configuration.md](configuration.md#writing-the-config-file). -## Cache +## Downloads -`bundle pull` and every extractor read bundles through the cache (`[paths] cache`): +The commands read the game files from the depot's `downloads/` directory (`[paths] depot`), which keeps the files +under their own names: ``` -/catalog_main_.bin the legacy main catalog (explicit main or no API/version pin) -/bundles/ bundles of the dependency closures, decrypted (UnityFS) -/raw/ raw CDN files stored as they are (e.g. CRI cue sheet data) -/catalogs// catalogs downloaded by `catalog browse` -/international/// - versioned catalog, bundles and raw files; isolated from old main caches +/downloads/catalog_main_.bin the main catalog (explicit main or no API/version pin) +/downloads/bundles/ bundles of the dependency closures, decrypted (UnityFS) +/downloads/raw/ raw CDN files stored as they are (e.g. CRI cue sheet data) +/downloads/catalogs// catalogs downloaded by `catalog browse` +/downloads/international/// + versioned catalog, bundles and raw files of one CDN and version ``` -Files already in the cache are not downloaded again. When `[paths] apk` is set, the APK's own catalog is merged with -the region's and bundles that ship inside the APK are read from it. +A file is downloaded when a command first needs it and read from `downloads/` while it is there. When `[paths] apk` +is set, the APK's own catalog is merged with the region's and bundles that ship inside the APK are read from it. + +The bundles and raw files are a cache bounded by `[depot] downloads`. Every command that reads the depot holds a +lease in `/leases/` while it runs; when the last running command ends, the bundles and raw files used least +recently are removed until the rest fit the budget: + +| `[depot] downloads` | kept once no command runs | +|---|---| +| unset or `0` | no bundles or raw files | +| a size, e.g. `20GiB` | the most recently used ones, up to that size | +| `all` | every one | + +Catalog files are kept. A removed file is downloaded again when a command needs its bytes. The depot records the +identity (sha256 and size) of every bundle and raw file it reads, so `export assets` plans with files whose name +carries the catalog's hash without downloading them again. + +During product and asset requests, a completed object-conversion batch makes its bundle downloads eligible for +release toward the same budget. Readers finish before release; active batches, failed batches and preliminary +episode requests stay protected. A batch already present in the depot can release its downloaded input as well. +Only files recoverable through an explicit catalog or depot locator are released, and their contents are checked +against the input identity. `all` retains downloads throughout the run; `0` releases each eligible batch without +scanning the download directory. With a finite size, finished files are evicted in LRU order and active files can +temporarily exceed the budget. Later requests can download a released bundle again. + +Commands may run at the same time on one depot: no command removes downloads while another one holds a lease, and a +command that starts while one trims waits for it. The lease of a process that ended without releasing it is dropped +by the next command; a lease written on another host counts for a day. For international regions with a configured API, catalog-based commands discover `resource_version` first and download `catalog__.bin`. `catalog fetch` labels the bytes with the version used to select that file, rather than querying a newer label after downloading main. Use `--catalog-version VERSION` or -the [version settings](configuration.md) to pin builds and use cached data offline. An explicit `--catalog FILE` +the [version settings](configuration.md) to pin builds and use downloaded data offline. An explicit `--catalog FILE` bypasses discovery. JP keeps its existing Version-driven path. ## catalog keys @@ -82,11 +109,11 @@ it, decrypted; `bundles/` lists every bundle directly. ## bundle pull ``` -nnnotes bundle pull KEY [KEY ...] +nnnotes bundle pull KEY [KEY ...] -o DIR ``` -Fetches the bundle closure (the key's location and all its dependencies) of each key into the cache and prints the -cached path of every bundle. +Fetches the bundle closure (the key's location and all its dependencies) of each key, copies the decrypted bundles +to `DIR` under their file names, and prints the path of every copied bundle. ## servers @@ -156,136 +183,94 @@ row of a Movie / Clip row's video id. ## export story ``` -nnnotes export story ADV_ID -o OUT [--models MODELS] [--force] [--format flac|ogg|wav] [--flac-level N] [--no-audio] - [--fonts open|game] -``` - -One ADV episode as a directory, and its Live2D models in a models directory: - -``` -MODELS// every Live2D model of the episode (MODELS: `--models`, default OUT/../live2d), the files - of a site's model manifest (see `site build`): model.json, moc3, prefab, the atlas pages and the - shader programs its drawables use -OUT/episode.json as `export episode` -OUT/audio// every cue sheet of the episode, one file per cue + cues.json, streams.json -OUT/scene.json player graphics, cameras, ADV fields, volumes, settings and stages -OUT/frames.json Frame prefabs by asset name: uGUI, Animator controllers and clips, UI particle systems -OUT/effects.json particle effect prefabs by asset name, `instances` (effect name -> asset name) -OUT/posteffects.json PostEffect volume profiles by asset name -OUT/stills.json Still prefabs by asset name -OUT/talkwindows.json talk window prefabs of the TalkWindow rows -OUT/chat.json chat window prefabs, icons and stamps (sprites), the MasterAdvChat rows, shared chat texts, - sounds and cue sheet rows -OUT/textures/, shaders/ textures and shaders of the scene and of the files above -OUT/videos/ Movie / Clip videos as WebM + videos.json (video id -> file, video row, size, frame rate) -OUT/ui/ ADV UI: ui.json (front canvas, still / frame / video canvases and their camera, letterbox), - packed textures, UI shaders, rule transitions -OUT/crilips/ crilips.json + crilips.bin: the CRI Lips analysis data, when a voice reaches the analysis -OUT/story.json index of the above (a file the episode does not need is null and not written) -``` - -story.json `models` maps the key of each model to its id (`` of `Character/Live2D///model/`) -and `modelsDir` is the path from OUT to MODELS. A model directory that exists is used as it is, so the stories of one -models directory export each model once; `--force` exports the episode's models again. The printed summary lists -the models exported (`modelsBuilt`) and those used as they were (`modelsSkipped`). Several `export story` processes may -share a models directory: a model appears in it in one rename, whole, and a model two processes export at once is -kept from the first to finish (the other's export, the same bytes, is dropped and counted in `modelsSkipped`). - -`--format` (default `flac`) is the audio format and `--flac-level` its FLAC compression level (as for `export audio`); -`--no-audio` leaves the cue sheets undecoded (no `audio/`, `audio` -in story.json is empty). With audio, `crilips/` holds the weights of the game's CRI Lips mouth analysis, read from the -CRI Lips library in `[paths] apk` (or in the arm64-v8a split next to it, `split_config.arm64_v8a.apk`), when a voice -reaches that analysis: a lip-synced voice while a model has no MotionSync controller (model.json `motionSync`), or a -voice that other speakers follow (story.json `crilips`; ournotes-player docs/crilips.md). Videos keep the VP9 stream -of the game's USM file and carry its ADX audio as Opus (FFmpeg, -`[paths] ffmpeg`); the USM streams are unmasked with the CRI key read from `[paths] apk`. TextMesh Pro text in -frames, talk windows and chat keeps its layout and style; with `--fonts open` its font and sprite assets and text -materials are only named (no atlas is written), with `--fonts game` they are exported. The command stops before -writing anything when the episode uses resources that are not in the catalog or resource kinds that are not -supported (`timeline`). Prints the index with a summary. - -The shared chat sound rows (`chat.json` `sounds`, `cueSheets`) name the cue sheet `AdvSe_Common`. In the version -1.0.1 data nothing holds it: no catalog language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) has a key -`Cri/Sound/AdvSe_Common` or `EmbCri/Sound/AdvSe_Common` (remote or APK catalog), the APK has no ACB file of that -name, and it is not a Resources asset. The story therefore has no audio for these rows; the rows are kept as they -are. Every cue sheet of the episodes' own `-SoundCueSheet` shards has its `Cri/Sound/` key. - -`ui/ui.json` holds the parts of the game's ADV widget the story draws: the front canvas (talk window, speaker plate, -location caption, title, rule transition, curtains, flash, subtitles caption, next indicator, menu entry button, menu -panel and video buttons, backlog, choices, tap areas), the canvases of stills, frames and videos with the screen -image and the camera they render with (`videoAndStillCamera`), the UI camera that renders the frame canvas -(`uiCamera`), and the letterbox bands; per node its transform, rect -and uGUI components, the views' serialized references as node paths (the menu view with its fast-forward sprites), -the other game components' serialized fields (`behaviours`), the widget's canvas sort orders (`widget`), the ADV -screen's dialogs (`dialogs`: the skip confirm and common dialog prefabs) and their texts of the language -(`masterIdTexts`), the chat phone (`chatWidget`), the text -records of the episode's chat windows (`chatTexts`) with their status texts (`chatStatusTexts`). The talk windows -sit under `TalkView`: the default window, -then each other window the episode's TalkWindow rows attach; when one of them dims and blurs the screen behind its -talk (`UICenterTalkWindow`), the front canvas' `CenterTalkBackdrop` node, the renderer's blur settings (`blur`) and -the blur shader come with it. - -`ui/` follows the client language `[catalog] language` (`ja`, `en`, `zh-Hant`, `zh-Hans` or `ko`): the localized -fonts and materials, the line spacing LocalizeText applies, and, with `--fonts game`, the characters of the lines -(Talk, Location and Subtitles rows, ruby readings included), speaker names, title and UI texts in that language -(`ui/ui.json` `language`: `mode`, `field`, `lineSpacing`). - -`--fonts` (default `open`) chooses how `ui/` handles text. Both modes write a text record `textStyle` for each text -node of `ui/ui.json`, and a document-level `textStyle` that holds the units and the line metrics of each font role. -With `open`, `ui/` contains no font data: no font atlases, glyph tables, font materials or text shader. `game` also -exports the game's TextMesh Pro fonts: per node the serialized text settings with the localized font and material -(`text`), and in the document the font assets reduced to the characters the episode shows (`fonts`), runtime glyphs, -text materials, `glyphCoverage`, `tmpSettings` and the text shader. `game` needs the optional `fonts` dependencies -(`pip install -e ".[fonts]"`). - -The `ui/ui.json` text record (`textStyle` of a node; the node's `rect` and transform give its layout box): - -| Field | Value | -|---|---| -| `class`, `enabled` | TMP component class, enabled flag | -| `fontRole` | font slot of the game's localized fonts: `primary` (main text face), `number` (Latin / numeral face) | -| `materialType` | material style name of the text (`Default`, `OutlineAdvCommon`, ...) | -| `localized`, `textKey` | LocalizeText enabled; its master text id, or null when the runtime sets the text (talk, speaker, location and title texts come from `episode.json`, one field per language) | -| `text`, `richText`, `parseControlCharacters` | serialized text, rich text tags on, `\n`-style escapes parsed | -| `fontSize`, `autoSize` | size in canvas units; auto-size `enabled`, `min`, `max`, `maxCharWidthAdjust`, `maxLineSpacingAdjust` | -| `fontStyle`, `fontWeight` | style flags (`bold`, `italic`, `underline`, `strikethrough`, `lowerCase`, `upperCase`, `smallCaps`, `superscript`, `subscript`, `highlight`), weight 100-900 | -| `alignment` | `horizontal` (`left`, `center`, `right`, `justified`, `flush`, `geometry`), `vertical` (`top`, `middle`, `bottom`, `baseline`, `geometry`, `capline`) | -| `wrapping`, `overflow` | `noWrap` / `normal` / `preserveWhitespace` / `preserveWhitespaceNoWrap`; `overflow` / `ellipsis` / `masking` / `truncate` / `scrollRect` / `page` / `linked` | -| `margin` | `left`, `top`, `right`, `bottom` insets of the rect | -| `lineSpacing` | `serialized`, `applied` (the value for the export language), `byLanguage` (the per-language line spacing LocalizeText applies; null when not localized) | -| `paragraphSpacing`, `characterSpacing`, `wordSpacing` | spacing in 1/100 em | -| `characterHorizontalScale`, `kerning`, `rightToLeft`, `orthographic` | as serialized | -| `color`, `colorMode`, `colorGradient`, `overrideHtmlColors` | vertex colour, gradient mode, four-corner gradient (null when off) | -| `face` | `color` (fill = `color` x `face.color`), `dilateEm`, `boldDilateEm` (glyph edge moved outwards), `softnessEm` (edge ramp width) | -| `outline` | null, or `color`, `widthEm` (band on each side of the glyph edge: stroke width 2 x `widthEm`, drawn over the fill), `softnessEm` | -| `underlay` | null, or `color`, `offsetEm` [x, y] (+x right, +y down), `dilateEm`, `softnessEm`, `inner` (shadow inside the glyph) | - -`*Em` values are fractions of the drawn font size, converted from the text material's distance-field properties the -way the TMP shader applies them. The document-level `textStyle.roles` gives, per role, `lineHeightEm`, `ascentEm`, -`descentEm` (line pitch = `lineHeightEm` + `lineSpacing` / 100 em) and the font's `spacingOffset` / `boldSpacing` -(1/100 em) for the export language. +nnnotes export story ADV_ID -o OUT [--format FORMAT] [--flac-level N] [--stored-catalog LABEL|SHA] [--workers N] + [--story-languages LANG,...] [--fonts open|game] [--font LANG=PATH] + [--link auto|clone|hard|copy] [--explain] [--dry-run] +``` + +One ADV episode and the products it plays with, in the `product` layout ([products](products.md#stories)): + +``` +OUT/stories/.json the story (`story`): episode.json and story.json +OUT/storyscenes/default.json the ADV scene (`story.scene`) +OUT/storyassets/.json each stage, frame, effect, post effect, still, timeline, transition, chat window, + chat icon and stamp the episode loads (`story.asset`; : its key, "/" as "~") +OUT/models/.json each Live2D model of the episode (`live2d.model`) +OUT/cuesheets/.json each cue sheet of the episode, decoded (`story.sound`) +OUT/videos/.json each video of the episode, as WebM (`story.video`; : its asset, "/" as "~") +OUT/storychat/default.json the shared chat rows, when the episode shows chat (`story.chat`) +OUT/storyui/.json the story UI in each requested language (`story.ui`) +OUT/storyfonts/_.json the episode's font records and glyph pages (`story.fonts`) +OUT/storysimpleui/.json the Overlay talk-window and system-message UI (`story.simpleui`) +OUT/storyhosts/.json the Overlay host screen (`story.host`) +OUT/spots/.json a home host's spot and Spine files (`spot.files`) +OUT/rooms/.json a home host's background (`room.gltf`) +OUT/crilips/default.json the CRI Lips analysis data, when a voice can reach it (`story.crilips`) +OUT/assets/. the files the manifests name +``` + +`episode.json` is the episode as `export episode` writes it, each resource given by its kind and address. +`story.json` names the episode's chat rows, its effect instances and, in `parts`, the manifest of each product above +it plays with. The command runs twice: it asks for the story, reads its `story.json`, then asks for the story and +every product `parts` names and places them. It stops with a usage error before the second run when a key of such a +product is not in the catalog. The episode's talk windows are listed in `episode.json` `resources`. The videos are +the `movie.webm` of `cri.movie` with `format` `webm`: VP9 as stored, re-encoded when the video is in another codec or +has an alpha stream. + +A product several stories play with (the scene, a stage, a model, a cue sheet) is one product: stories exported into +one directory share its manifest and files, and the depot keeps every result for the next export. + +`--story-languages` selects text languages, separated by commas; the default is `[catalog] language`. +`--fonts open` (the default) generates glyph pages from the font files in `[paths.fonts]` or the repeatable +`--font LANG=PATH` flags. Supply each requested language's font and any fonts needed by its fallback chain. +`--font emoji=PATH` supplies a bitmap emoji font. Without it, open emoji sprites retain their layout with empty +images. `--fonts game` uses the client's TMP glyphs and generates the runtime glyphs its font assets require. +Both sources use font settings from the client's assets and require the `fonts` extra. Font files are explicit +content-addressed inputs, so changing their bytes rebuilds the corresponding font product. + +Overlay episodes also request their home or live-result host, the simple UI in each requested language, and, +for home hosts, the shared spot and room products. The host's master-data relationship is recorded in +`story.json` `host`; the episode font product covers simple UI text and any area title in both font modes. + +`--format` (default `aac`) is the web audio format of the cue sheets: `aac` (AAC-LC in MP4, `cues.json` records each +file's encoder delay), `opus`, `vorbis`, `mp3`, or `flac` (the decoded FLAC as it is); `--flac-level` is the FLAC +compression level of the decoded sounds (as for `export audio`). `crilips/` holds the weights of the game's CRI Lips +mouth analysis, read from the CRI Lips library in `[paths] apk` (or in the arm64-v8a split next to it, +`split_config.arm64_v8a.apk`); `story.json` names it when a voice of the episode is lip-synced, and the models' +MotionSync (model.json `motionSync`) decides whether a player runs the analysis. + +The shared chat sound rows (`chat.json` `sounds` and `cueSheets` of `storychat/default.json`) name the cue sheet +`AdvSe_Common`. In the version 1.0.1 data nothing holds it: no catalog language (`ja`, `en`, `zh-Hant`, `zh-Hans`, +`ko`) has a key `Cri/Sound/AdvSe_Common` or `EmbCri/Sound/AdvSe_Common` (remote or APK catalog), the APK has no ACB +file of that name, and it is not a Resources asset. The story therefore has no audio for these rows; the rows are +kept as they are. Every cue sheet of the episodes' own `-SoundCueSheet` shards has its `Cri/Sound/` key. ## export live2d ``` -nnnotes export live2d MODEL -o OUT +nnnotes export live2d MODEL -o OUT [--player] ``` -A Live2D (Cubism) model prefab as a Cubism runtime directory. `MODEL` is the model's key +A Live2D (Cubism) model as the Cubism SDK files other tools read. `MODEL` is the model's key `Character/Live2D///model/` or its id `` (`nnnotes catalog keys --prefix Character/Live2D/` lists the keys): ``` OUT/.moc3 the model's moc3 -OUT/textures/*.png atlas pages -OUT/.prefab.json the whole prefab: every GameObject and component, motions and expressions inlined -OUT/.exp3.json expressions +OUT/textures/.png the atlas pages the drawables draw with, by name OUT/.physics3.json physics (not written for a model without physics) -OUT/motions/.motion3.json motions (and motions/_fades.json) -OUT/.model3.json file references, EyeBlink / LipSync groups +OUT/.exp3.json expressions +OUT/motions/.motion3.json motions +OUT/.model3.json file references, the model's fade times per motion, EyeBlink / LipSync groups ``` -Needs `[paths] apk` (component classes are resolved through the APK). Prints a JSON summary. +With `--player`, the model the player reads instead: the product layout of `live2d.model`, `OUT/models/.json` +and the files it names under `OUT/assets/` ([products](products.md#live2d-models)). + +The command runs the depot's pipeline for the model alone (the census of the bundles of its key, the objects its +prefab reaches, the product) like `export assets`, and writes the same reports in `OUT/_reports/`; it takes +`--stored-catalog`, `--workers`, `--link`, `--explain` and `--dry-run` as `export assets` does. Needs `[paths] apk` +(the component classes and the mask materials are in the APK). ## export spot @@ -296,42 +281,26 @@ nnnotes export spot SPOT_ID -o OUT ``` OUT/spot.json master row, names, tap-talk episodes, situation settings, tap targets, Spine characters OUT/spine/ Spine skeletons (.json or .skel), atlases, atlas page PNGs -OUT/room.glb the spot's background as binary glTF (as `export room`), with room.json -OUT/shaders/ shaders of the spot's bundles (as `export shader`) +OUT/room.glb the spot's background as binary glTF (as `export room`) +OUT/room.json its summary ``` -Needs `[paths] apk` and master data (`MasterHomeSpot`, `MasterText`, `MasterStoryHomeSpotTapTalkEpisode`). `room.glb` and -`room.json` are the `export room` export of the spot's background. - -A reference the situation prefab leaves empty is written as null: a Spine character with no `_animation` has null -`skeletonData`, `animation` and `world`, and a tap target with no `_focus` has a null `focusWorld`. +The spot whose `MasterHomeSpot` row has the id `SPOT_ID`: the products `spot.files` of its situation +(`_situationAssetPath`) and `room.gltf` of its background (`_backgroundAssetPath`), their files at their own paths +([products](products.md#spots)). The command runs the depot's pipeline for the two products alone, as `export live2d` +does, writes the same reports in `OUT/_reports/` and takes the same options. Needs `[paths] apk` (the Spot and Spine +component classes are in the APK) and master data (`MasterHomeSpot`, `MasterText`, +`MasterStoryHomeSpotTapTalkEpisode`). ## export room ``` -nnnotes export room KEY -o OUT.glb +nnnotes export room KEY -o OUT ``` -A spot background prefab as binary glTF: every mesh baked into prefab space, converted to glTF's right-handed space, -textures embedded, materials translated from the shader's render state. Objects inactive in the prefab are kept with -`extras.unityActive = false`. A summary (`meshCount`, materials, textures, samplers) is written next to it as -`OUT.json`. - -Materials and textures are told apart by serialized file and path id (the prefab's bundle and its dependencies can -reuse a path id). Each texture is one glTF texture with its own sampler, and textures whose PNG bytes are equal share -one glTF image. The summary's `textures` has one entry per glTF texture, in glTF order (the index a material's -`baseColorTexture` names): the Unity texture's name. - -The render state is that of the shader's first pass for the material: blend factors (colour and alpha), blend -operations, colour mask, culling, depth write, depth test and alpha to mask. A state the shader takes from a -property (`Blend [_SrcBlend] [_DstBlend]`, `Cull [_Cull]`, `ZWrite [_ZWrite]`, ...) is the material's value of that -property, else the shader's default for it. Each glTF material keeps the resolved state in -`extras.unityRenderState` (and `OUT.json` in `materials[].renderState`); a shader or a state glTF cannot express -stops the command. A submesh whose material slot is empty is not written (there is no material to draw it with): the -summary lists each as `nullMaterialSubmeshes` (`mesh`, object `path` in the prefab, `submesh` index), and a mesh -left with no submesh is not written. A MeshFilter with no mesh, or a mesh with no triangles (no index data), draws -nothing and is not written; the summary lists the object path in `nullMeshFilters`, or the mesh and object path in -`meshesWithoutTriangles`. +A spot background as binary glTF, `OUT/room.glb`, with its summary `OUT/room.json`: the product `room.gltf` of the +background key `KEY` (`Spot//Background/`; [products](products.md#spots)). Runs and reports as +`export spot`. ## export shader @@ -419,266 +388,122 @@ nnnotes apk settings -o OUT.json The game's render settings from the APK's boot data as JSON: `colorSpace`, `defaultPipeline`, `qualityLevels`, `qualityPerPlatform`, `pipelines`, `renderers`, `postProcessData`. Needs `[paths] apk`. The MonoBehaviours are read with the type trees that ship with nnnotes; an APK whose classes do not match them stops the command with exit -status 2 and a line naming the class, the game version and the Unity version (as `export story`, `export live` and `site build`). +status 2 and a line naming the class, the game version and the Unity version (as `export story` and `site build`). ## export live ``` -nnnotes export live MUSIC_ID -o OUT [--difficulty easy|normal|hard|expert] [--format flac|ogg|wav] - [--flac-level N] [--fonts open|game] [--band BAND | --leader-card CARD_ID] +nnnotes export live MUSIC_ID -o OUT [--difficulty easy|normal|hard|expert] [--format aac|flac|mp3|opus|vorbis] + [--flac-level N] [--band BAND | --leader-card CARD_ID] [--live-option OPTION[=VALUES] ...] + [--live-languages LANG,...] [--fonts open|game] [--font LANG=PATH] ``` -One chart (music + difficulty, default `expert`) as a self-contained directory, the format ournotes-player reads: +One chart (music + difficulty, default `expert`) and the products it plays with, in the product layout +([products](products.md#lives)): ``` -OUT/score/ the chart as shipped, the converted runtime notes, master rows (title and band - names in every language), summary.json -OUT/audio// the BGM cue sheet decoded per cue + cues.json, streams.json -OUT/audio/live-audio.json the live's sounds (BGM, note SE, live SE: the start and finish cheers and the finish - sound of every result, all perfect, full combo, assist full combo and clear) with - their CRI routing; their cue sheets decoded next to the BGM -OUT/livescene/ scene graph, cameras, lane, background, start timeline, textures and shaders -OUT/livenotes/ note, line and effect prefabs, skins, clips, particle systems, textures and shaders -OUT/liveui/ the start canvas in [catalog] language: strings, text records or fonts, sprites -OUT/live.json index of the above +OUT/lives/_[_].json the live: the chart as shipped, its converted notes (also mirrored), + the music's master rows, the jacket, the manifest paths of the parts +OUT/scenes/default.json the live scene: scene graph, cameras, lane, UI assets, the start + timeline and LightWeight background of every band, the built-in + graphics settings +OUT/notes/default.json the note highway: note, line and lane effect prefabs, settings +OUT/noteskins/.json a note skin +OUT/noteeffects/.json a note effect set +OUT/sounds/.json the note and live sounds with their CRI routing and volumes +OUT/music/.json the BGM in a web format +OUT/liveui/__.json localized start-canvas text, glyph pages and image bindings +OUT/assets/. every file the manifests name, once ``` -`--format` (default `flac`) is the format of every decoded cue sheet and `--flac-level` its FLAC compression level -(as for `export audio`). - -The start canvas follows the client language `[catalog] language` (`ja`, `en`, `zh-Hant`, `zh-Hans` or `ko`): its -text table column, fonts and line spacing. `--fonts open` (default) writes each text's layout and style (size, -alignment, wrapping, spacing, colours, outline / underlay from its material) and only the names of the game's fonts -and materials; `--fonts game` also writes the game's TextMesh Pro fonts reduced to the characters shown, with their -atlas textures (needs the optional `fonts` dependencies: `pip install 'nnnotes[fonts]'`). +`--format` (default `aac`) is the web format of the BGM: `aac` (AAC-LC in MP4), `opus`, `vorbis`, `mp3`, or `flac`, +the decoded FLAC itself. `--flac-level` is the FLAC compression level the cue sheets are decoded at (every level +decodes to the same samples). The game takes the band of the background and start timeline from the player's deck centre. Without a deck the band is `--band`, or the band of the character of `--leader-card` (a `MasterMemberCard` id), or by default the band -of the music's first vocal character; the choice is recorded in `livescene/scene.json`. Prints the index with a -summary. - -The directory holds what a live reads with the game's default options (option preset 1 of a fresh profile). -`--live-option` (repeatable) adds the files of other values of the options that select files, so that a player can -offer them; the options are named as in the game (the names ournotes-player's settings use): +of the music's first vocal character; `live.json` `band` records the choice, and the live's manifest path holds the +band only when one of the two flags is given. A band whose start timeline or background the catalog does not hold is +a usage error. + +`--live-languages` selects UI languages separated by commas (`all` selects all client languages); the default is +`[catalog] language`. `--fonts open` (default) generates glyph pages from `[paths.fonts]` or repeatable +`--font LANG=PATH` flags. Supply fonts for the requested language and its fallback languages. `--fonts game` uses +the client's TMP assets. Both modes require the `fonts` extra and use the client's font metrics and layout +settings. Font bytes participate in the task key. The UI's canvas and difficulty icons are in `live.scene`, and +its jacket and text shaders are in the UI product's object table. `live.json` names UI products by language in +`parts.ui`. + +The products hold what a live reads with the game's default options (option preset 1 of a fresh profile), and always +the mirrored notes, the bar line view and the option tables. `--live-option` (repeatable) adds the products of other +values of the options that select files, so that a player can offer them; the options are named as in the game (the +names ournotes-player's settings use): | `--live-option` | Adds | |---|---| -| `MirrorChart` | `score/.mirror.notes.json`, the chart converted with lanes and flick directions mirrored (the game mirrors while it converts the chart); `live.json` `notesMirror` | -| `NoteDesignId` | the other note skins (`MasterLiveNoteSkin`): `livenotes/notes.json` `noteSkins` (skin asset name -> a record like `noteSkin`) and `settings.skins` (`NoteDesignId` -> skin asset name) | -| `NoteEffectId` | the other note effect sets (`MasterLiveNoteEffectSkin`; the lane effects are the same for every set): their assets in `livenotes/notes.json` `assets`, and `settings.effects` (`NoteEffectId` -> effect set name) | -| `LiveQuality` | the qualities of `MasterLiveQualitySettings`; at the Low quality (2) the game loads the `Light` variant of a note effect set where the catalog has one (else the set itself), whose assets are added (and `settings.effects`) | -| `NoteSePatternId` | the other note sound sets (`MasterLiveNoteSe` groups): `audio/live-audio.json` `noteSe.groups` (set -> `LiveNoteSeType` -> sound id) and their sounds, decoded next to the others | -| `MeasureLineDisplay` | the bar lines: `livenotes/notes.json` `prefabs.bar_line_view`, the bar line view the live scene's bar line container instantiates (a node list like the note view prefabs), and its sprite's texture; the bar times and the BPM and time signature changes are in the score's notes (`barLineTimeMs`, `bpmChanges`, `barChanges`) with or without the option | -| `defaults` | only `settings.optionDefaults` / `optionRanges` of every option the player offers (note timing, the live and note sound volumes and mutes, the note sound set and per-type sounds, ...) | +| `NoteDesignId` | the other note skins (`MasterLiveNoteSkin`): a `live.noteskin` product each | +| `NoteEffectId` | the other note effect sets (`MasterLiveNoteEffectSkin`; the lane effects are the same for every set): a `live.noteeffect` product each | +| `LiveQuality` | the qualities of `MasterLiveQualitySettings`; at the Low quality (2) the game loads the `Light` variant of a note effect set where the catalog has one (else the set itself), whose `live.noteeffect` products are added | +| `NoteSePatternId` | the other note sound sets (`MasterLiveNoteSe` groups): the `live.sounds` product `NoteSePatternId=`, which holds them and the default set | +| `MirrorChart`, `MeasureLineDisplay`, `defaults` | nothing: the products always hold their files | | `all` | every option above with every value | An option without values offers every value the master data has; `NAME=v,v,...` offers those values (the default -value is always included; a value the master data does not have is a usage error). Any option also writes the -`defaults` tables. Without `--live-option` the directory holds the files of the default options only. +value is always included; a value the master data does not have is a usage error). + +The command runs the depot's pipeline for these products alone, as `export live2d` does, writes the same reports in +`OUT/_reports/` and takes `--stored-catalog`, `--workers`, `--link`, `--explain` and `--dry-run` as `export assets` +does. Needs `[paths] apk` (the built-in graphics and sound settings and the CRI ACF are in the APK), master data, +`vgmstream` and `ffmpeg`. ## site build +```sh +nnnotes site build --name NAME -o SITE [--pair MUSIC_ID:DIFFICULTY | --all] + [--live2d MODEL | --all-live2d] [--story ADV_ID | --all-stories] + [--player DIR] [--format aac|opus|vorbis|mp3|flac] [--no-audio] + [--compress gzip|br|none] [--workers N] [--memory GiB] [--fetch-workers N] + [--stored-catalog LABEL|SHA] [--site-region REGION | --all-regions] + [--fonts open|game] [--story-languages LANGS] [--font LANG=PATH] + [--band N | --leader-card ID] [--live-option OPTION[=VALUES]] +nnnotes site build --name NAME -o SITE --player-only +``` + +The [site guide](site.md) covers selection, fonts, read sets and hosting. Chart, model and story requests execute the same depot product stages as their export commands. `--name` (default `default`) is a persistent namespace in the depot. Partial selections retain other topics; complete selections replace that product kind in the requested regions. + +```text +SITE/ + index.html, chart-list.js, ... chart list and player page + live2d/, story/, songs/ player pages and bundles + charts.json, models.json, + stories.json listings and localized facts + site.json complete nnnotes.site/1 catalog + charts/.json live product entry manifests + models/.json model product entry manifests + stories/.json story product entry manifests + charts//.json, + stories//.json regional variants + products/.json immutable nnnotes.product/1 manifests + assets/.[.gz|.br] shared asset bytes + manifest.json output ownership ``` -nnnotes site build -o SITE [--pair MUSIC_ID:DIFFICULTY [--pair ...] | --all] [--live2d MODEL [--live2d ...] | --all-live2d] - [--story ADV_ID [--story ...] | --all-stories] [--story-languages LANG,...] [--font LANG=PATH ...] -nnnotes site build -o SITE (--player-only | --reingest-json) - [--player DIR] [--format aac|opus|vorbis|mp3|flac] [--no-audio] [--compress gzip|br|none] - [--force] - [--tmp DIR] [--workers N] [--read-workers N] [--band BAND | --leader-card CARD_ID] - [--site-region REGION [--site-region ...] | --all-regions] [--fonts open|game] - [--live-option OPTION[=VALUES] ...] -``` - -Builds or updates a static [ournotes-player](https://github.com/empty-sekai/ournotes-player) site of charts, Live2D -models, stories or any of them: - -``` -SITE/index.html, chart-list.js ... the player's chart list page; ?music=&difficulty= plays one chart -SITE/ournotes-player.element.min.js the player's built bundle (and its source map) -SITE/charts.json chart index: listing facts (texts in every language), regions, - manifest path, sizes -SITE/charts/_.json chart manifest: every path the player reads -> {asset, size[, stored]}, - or {parts: [[key, asset, size[, stored]], ...], size} for a large JSON - object -SITE/charts//.json the manifest of a region whose chart files differ (see Regions) -SITE/models.json Live2D model index: id, key, group, canvas, manifest path, sizes; with - master data the character and its names -SITE/models/.json model manifest, the same entry forms as a chart manifest -SITE/live2d/ the player's Live2D model page and its bundle (when the player has them) -SITE/stories.json story index: titles and story groups in every language, commands, - languages, manifest path, sizes, regions -SITE/stories/.json story manifest: the common files and one file group per language, the - same entry forms as a chart manifest; the model manifests of its models - and the path to the site root -SITE/stories//.json the manifest of a region whose story files differ (see Regions) -SITE/story/ the player's story page and its bundle (when the player has them) -SITE/assets/.[.gz|.br] content-addressed files shared by all charts, models and stories - (see --compress) -``` - -- `--pair` (repeatable; `:` or `_`) adds the given charts (a chart that - no region of the site has a `MasterLiveMusicScore` row for is a usage error); `--all` adds every music and - difficulty that has a `MasterLiveMusicScore` row (in the master data of any of the site's regions). -- `--live2d` (repeatable; a model id `` or key `Character/Live2D///model/`) adds the given - Live2D models; `--all-live2d` adds every model key of the catalog. A model's id is its ``. Its manifest lists - the files the player's Live2D viewer and story page read: `model.json` (index: moc3, prefab, textures, shader index, - the Cubism mask materials and `motionSync`, whether the prefab's root has the MotionSync controller with its CRI - audio input), the moc3 and prefab as `export live2d` writes them, the atlas pages the drawables use, and the GLSL ES 3.00 - programs of the Live2D shaders that the drawables' materials select, each keyword set also with - `_ADDITIONAL_LIGHTS_VERTEX` (the story player adds it at quality 4), and the mask shader's for a model with masked - drawables. Charts and models can be added in one run; models are built first. -- `--story` (repeatable; a `MasterAdv` id) adds the given story episodes (an id no region of the site has is a usage - error); `--all-stories` adds every `MasterAdv` episode. See [Stories](#stories) below. Stories are built after - models and charts; the Live2D models the stories to build use are built before them, as with `--live2d` (listed in - `models.json`, names from the master data as for `--live2d`). -- A chart, model or story whose manifest exists is skipped unless `--force` (with stories, their models too). A - model manifest whose `model.json` is of an older format (without `motionSync`) is outdated and built again; the - summary lists such models in `modelsRebuilt` (`storyModels.modelsRebuilt` for the models of stories). Assets no - chart, no model and no story (common or language file, or model manifest it names) references are removed. -- `--player-only` rewrites the player files, `charts.json`, `models.json` and `stories.json` only; `--reingest-json` - stores every chart's and model's JSON files again from the site's own assets (then rewrites the player files and - the indexes; story manifests are left as they are). -- `--player`: the ournotes-player checkout (after its build) or installed package; it must contain - `scripts/read-set.mjs`, `dist/ournotes-player.element.min.js` and `examples/chart-list/index.html`. The read-set - script runs under Node.js to list the files the player reads for each chart; only those are stored. When it also has - `examples/live2d/index.html`, that page and `dist/ournotes-player.live2d.element.min.js` go to `SITE/live2d/`; - when it has `examples/story-list/index.html` and `dist/ournotes-player.story.element.min.js`, they go to - `SITE/story/` (a build that adds stories stops when the page is there without its bundle). -- `--compress` (default `gzip`): how the compressible assets are stored: JSON files (split parts included), GLSL, - moc3, atlas, skel, bin, wav and glb files. With `gzip` or `br` (brotli, quality 11) such a file is stored encoded - when that makes it smaller: `assets/..gz` (or `.br`), named by the SHA-256 of the decoded bytes; its - entry's `size` is the decoded size and `stored` the size of the stored file (a part: `[key, asset, size, stored]`). - Other files, and every file with `none`, are stored as they are, without `stored`. `--reingest-json` stores the - JSON files again with the given `--compress`. -- Hosting: the player decodes a `.gz` or `.br` asset when the bytes it receives have the entry's `stored` length and - uses them as they are when they have its `size`. A gzip site can be served as plain files; `Content-Encoding: - gzip` on its `.gz` files is optional (with it the browser decodes them, without it the player does). A brotli site - needs `Content-Encoding: br` on its `.br` files for Chromium-based browsers, which cannot decode brotli in the - page. -- `--format` (default `aac`) is the BGM format of the charts and the format of every story sound (music, sound - effects, voices); the charts' note SE, cheers and voices stay FLAC. `--no-audio` stores no audio (the player then - runs the chart silent on its own clock, and plays stories without their sounds; story videos keep their sound - track). -- `--workers`: parallel music processes (default a quarter of the CPUs, up to 8), model processes (default up - to 4) and story processes (default a quarter of the CPUs, up to 8); `0` or `1` builds in this process. `--read-workers`: chart read sets run at a time, each a Node.js process - (default half the CPUs, up to 16). With a player whose read-set script lists a chart's files from its plan - (without stepping the chart) and serves many charts from one process, the plans run in one long-lived Node.js - process per read-set slot, ended with the build; the full simulations that check a sample of the plans still run - in a process each. -- `--tmp`: directory for the temporary live and model builds (default `SITE.tmp`); the build directories in it are - removed after use. `SITE.tmp/cache/` is kept: decoded cue sheets, encoded PNGs, shader dumps and read sets, each - stored under a hash of everything it was made from (input bytes, settings, the external tools and libraries in use - and nnnotes' own code), so a later build with the same `--tmp` reuses what is unchanged and writes the same files - faster. Deleting it is - safe at any time outside a build; the next build then makes everything again. -- `--band` / `--leader-card`: as for `export live`, for every chart. `--fonts`: for charts as for `export live` (the site stores - no start canvas files); for stories where the glyphs of the story text come from (see Stories). -- `--live-option` (repeatable): as for `export live`, for the charts of this run (with `--pair` or `--all`). The live - directories carry the variants' files, and a chart's read set is the union of its read set with the default - options and its read sets with the settings of each offered variant (every combination of the offered mirror, - note skin, effect set and quality values, the bar lines on, and each other note sound set; the player's read-set - script needs its `--settings`); only the files a variant reads are stored, the shared ones once per site. A chart - manifest lists the offered qualities as `options` (`{"LiveQuality": [0, 1, 2]}`); the other options are offered by - the chart's files. Each variant adds a read-set plan per chart (`all` makes several dozen), so such a build takes - longer. Charts whose manifest exists keep the options they were built with unless `--force`. -- Models need `[paths] apk` (component classes and the mask materials are read from the APK). Master data is - optional: when it is configured (of the site's first region), each model that a `MasterCharacterCostume` row maps - to a character (its `_live2dPath` is the key after `Character/Live2D/`) gets, in its manifest's `model` and its - `models.json` entry, `character` (the `MasterCharacter` id), `names` (`{language: the character's name}`, from the - `MasterText` row of `_nameTextID`, the languages that have a text) and `label` (the name in `[catalog] language`). - A key the rows map to two characters gets none. The manifests of skipped models get this build's fields; without - master data the fields are not written and skipped manifests keep theirs. -- `--site-region` (repeatable) / `--all-regions`: the regions the site serves (see Regions); default: the one - `[catalog] region`. The global `--region` sets `[catalog] region`. + +Product manifests retain logical file paths, object IDs and file roles. Their declared dependencies point to immutable products. Public aliases have roots relative to their depth. Asset entries support whole and split JSON, with decoded sizes and optional encoded sizes. + +The complete catalog and layout are committed to the depot after publication succeeds. `--player-only` restores that catalog to the chosen output with current player pages. A configured remote supports restoration from a fresh local depot. Layout reachability keeps the retained products and assets available to depot collection. + +Read-set tasks freeze player sources, product inputs, Node identity and settings. Plans are checked against full chart simulations on the first chart and a deterministic sample. Plan failures fall back to full simulation; mismatches switch subsequent reads to full. Variants union every offered setting combination's read set. Structured documents and their image/audio/WebGL2 dependencies remain available; additional binary file roles are selected from the reads. + +Publication orders assets before immutable products, aliases/pages, and indexes. Retired owned files are collected after replacements succeed. Failures preserve the journal and previous successful-run pointer; retry the request after correcting the error. Product failures stop publication and appear in the run report. `--json` prints structured task and placement counts. ### Stories -A story's manifest lists what the player's story page reads, in the layout of `export story` (below the site's `assets/`): -`story.json`, `episode.json`, `scene.json`, the scene shaders (GLSL ES 3.00 programs only), textures, per cue sheet -`cues.json` and the file of each cue in the `--format` (an AAC file's encoder delay in -`cues.json` as `encoderDelay`; the CRI category names a cue plays in as `categories`, e.g. `["Voice", "VoiceConfig"]`), the media files the episode uses and its videos; per language (`--story-languages`, -default `ja,en,zh-Hant,zh-Hans,ko`) the story UI in that language (`ui/ui.json`), its font data (`ui/fonts.json` with -glyph pages under `ui/fonts/`) and `ui/languages.json`. UI files that are equal in every language are common files. -`scene.json` and `ui/ui.json` are stored split per top-level key, so the parts stories and languages share are stored -once. The Live2D models are the site's: the manifest names the model manifest of each model the story uses -(`models`, `{id: "models/.json"}`, relative to the site root; `root` is the path from the manifest to the site -root), and `stories.json` counts their files in `size.models`. The format is described in the player's -`docs/story-data-format.md`. - -- Story text is laid out with TextMeshPro's rules from TextMeshPro font assets holding exactly the characters the - episode shows in the language (its text table, title and UI labels). `--fonts open` (default) generates them from - a font file per language: `--font LANG=PATH` (repeatable) or `fonts.` in the `[paths]` table of the config - file (`NNNOTES_PATHS_FONTS_`, e.g. `NNNOTES_PATHS_FONTS_ZH_HANT`); every language of `--story-languages` - needs one. Any OpenType or TrueType font (the first face of a collection) with the characters of the language - works. The game's fallback chains are mirrored: where the game takes a character from a fallback font asset of - another language's font (Korean text falls back to the Japanese font, for example), the generated asset falls - back to an asset of that language's font file, which then needs to be given too (the build stops with the setting - it needs when a character reaches it); characters no font file of the chain has are listed in `ui/fonts.json` - `coverage.missing`. Characters the game's own font assets lack are drawn as the game draws them, as its missing - glyph (U+25A1 where the game's font has it), not from the font file (`coverage.missingGlyph`). The simple talk - window of an Overlay story is handled the same way. Each generated asset takes the - point size, padding, style settings and render mode of the game font asset the texts use in that language, so the - game's text materials apply unchanged; those values, the game's fallback chains, the characters the game's assets - have and the text materials come from the table of them shipped with nnnotes (`src/nnnotes/unity/fontsettings.json`, - `fontsettings.FORMAT`), so an open-font export reads no game font asset, material or font bundle; - its face info and glyph metrics come from the font file (FreeType, no - hinting), its distance field from the generator `--fonts game` uses for runtime glyphs (supersampled render modes - at up to 8x). The asset records the font's family, version, license and SHA-256. `--fonts game` uses the game's - font assets instead (the `fonts` extra); the format is the same. No font file is stored in the site other than as - the generated glyph pages. -- Emoji are laid out as the game lays them out: its UI texts (the talk and chat texts) draw a character their font - assets lack from the game's emoji sprite asset, and emoji sequences (several code points with U+200D or U+FE0F) - become sprite tags through the game's emoji search. The layout keeps the game's sprite metrics; the images come - from a colour emoji font of your own: `--font emoji=PATH` or `fonts.emoji` in the `[paths]` table - (`NNNOTES_PATHS_FONTS_EMOJI`), a font with PNG bitmap glyphs (CBDT or sbix tables). Noto Color Emoji (SIL Open Font - License 1.1) is the tested font. Only the sprites an episode's texts can draw are generated, each at the game's - sprite size, into one page under `ui/fonts/`; `ui/fonts.json` records the emoji font as it records a text font. - Without an emoji font the sprites keep their layout with empty glyphs, and `ui/fonts.json` - `coverage.sprites.missing` lists them. `--fonts game` uses the game's sprite asset. -- Stories need `[paths] apk` and the optional `fonts` dependencies (`pip install 'nnnotes[fonts]'`) in both modes. -- The story index's groups come from the story tables of the master data (`MasterStoryEpisode` and its chapter, - `MasterStoryFriendshipEpisode`, `MasterStoryHomeSpotTapTalkEpisode`, `MasterStoryLiveResultEpisode`, - `MasterHomeSpot`), with chapter, character and spot names in every language. -- An Overlay episode (`MasterAdv._playbackMode` 1: the game plays it in its simple ADV player over the screen that - opened it) is listed with `playbackMode` 1. With `--fonts open` its story also has the host screen (`host/`: the - home spot or the live result screen of its story group) and, per language, the simple talk window - (`ui/simple/ui.json`, `ui/simple/fonts.json` and its glyph pages); the manifest names them in `host`. The host has - no game-font variant: with `--fonts game` an Overlay story has neither. -- With `--fonts open` the chat window texts of an episode with chat rows get their bindings in `ui/fonts.json` - `chatTexts`, and the font assets also hold the chat windows' status texts and the texts they format at run time. - The text nodes of the episode's frames get theirs in `frameTexts`, and the font assets also hold the texts a frame - that receives texts is given at run time (the texts of its Frame rows' `TargetTextIDs`, as the frame formats them). -- Stories that fail are listed in the printed summary and in `SITE.story-failures.json`; the exit status is then 1. +Story entry products reference their episode, scene, models, assets, cue sheets, videos, localized UI/font products and optional host products. The selected languages default to the catalog language. Story models also appear in the model listing. `--no-audio` retains cue metadata while omitting the sound streams; playback uses silent timing. ### Regions and languages -One site serves several regions and every language: - -- The regions serve the same catalog for a language, so a chart's files depend on the region's master data only. - Regions whose chart tables (the master tables the chart build reads) are identical share one manifest, - `charts/.json`, built once from the first region's data. A region with other chart tables is built on its own - into `charts//.json`; such a manifest whose files equal the shared one's is dropped and the region joins - the shared manifest. Models read no master data: one build serves every region. Stories follow the charts' rule - with the master tables the story build reads (`stories/.json`, `stories//.json`); a region offers - the stories of its `MasterAdv`. -- Each region's master data: `[servers.] master` (else `[paths] master`, for at most one of the regions; the - global `--master` flag is refused with more than one region). A region offers the charts its master data has a - `MasterLiveMusicScore` row for. -- Chart files come from the catalog of `[catalog] language`; the listing texts come from the text tables in every - language: a manifest's `chart` has `title` and `bands` in `[catalog] language` (`language`), and `titles` / - `bandNames` in `ja`, `en`, `zh-Hant`, `zh-Hans` and `ko`. The manifest's `regions` lists the regions it serves; - regions accumulate over builds (a skipped chart gains the regions of the build), so a region is dropped by building - the site again from scratch. -- `charts.json` has one entry per manifest (an id appears once per region) with `regions`, `titles`, `bandNames`, - and at the top `language` (the default listing language: `[catalog] language` of the latest build), `languages` - and `regions` (`id`, `name` from `[servers.] name`, `languages` from `[servers.] languages`). The - chart list page switches with `?region=&lang=`. - -The same inputs with the same versions of nnnotes, its libraries and tools (and the same font files) give -byte-identical outputs; the encoded assets also need the same zlib and brotli versions. Charts that fail are listed -in the printed summary and in `SITE.failures.json`, models that fail in the summary and in -`SITE.model-failures.json`, stories in `SITE.story-failures.json`; the exit status is then 1. +Configured regions use their own master data. Products with identical immutable manifests and listing metadata share an alias and list all regions they serve. Other versions use regional paths. JP stories/models and international releases use separate sites; conflicting contents for the same model ID require separate sites. Chart and story listings include localized titles, supported languages and region metadata for the player pages. ## music-data diff --git a/docs/configuration.md b/docs/configuration.md index 2cfcb5a..a5419a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -59,7 +59,7 @@ No `config` command prints a setting's value. The config files these commands wr The global flags go before or after the command name: ```bash -nnnotes --config ~/nnnotes.toml --cache /data/nnnotes-cache bundle pull +nnnotes --config ~/nnnotes.toml --depot /data/nnnotes-depot bundle pull -o bundles NNNOTES_PATHS_MASTER=/data/master nnnotes export episode 10462 -o episode.json ``` @@ -86,7 +86,7 @@ exist when they are given. | `[master] key` | `NNNOTES_MASTER_KEY` | — | Rijndael-256 key of the master data files: 32 bytes as 64 hex digits | | `[master] iv` | `NNNOTES_MASTER_IV` | — | Rijndael-256 CBC initialization vector: 32 bytes as 64 hex digits | | `[catalog] region` | `NNNOTES_CATALOG_REGION` | `--region` | name of one `[servers.]` table | -| `[catalog] language` | `NNNOTES_CATALOG_LANGUAGE` | `--language` | catalog language: the `` of `catalog__.bin`: `ja`, `en`, `zh-Hant`, `zh-Hans` or `ko`; also the client language of `export live`, `export story` and `site build` (the text field, fonts and line spacing of their UI) and of the model labels of `site build --live2d` | +| `[catalog] language` | `NNNOTES_CATALOG_LANGUAGE` | `--language` | catalog language: the `` of `catalog__.bin`: `ja`, `en`, `zh-Hant`, `zh-Hans` or `ko`; also the client language of `site build` (the text field, fonts and line spacing of its story UI) and of the model labels of `site build --live2d` | | `[catalog] version` | `NNNOTES_CATALOG_VERSION` | `--catalog-version` | optional international resource version; unset: query the configured region API, or use legacy `main` when no API is configured; JP uses its own discovery | | `[servers.] name` | `NNNOTES_SERVERS__NAME` | — | label of the region in `catalog browse` (default: the region name) | | `[servers.] provider` | `NNNOTES_SERVERS__PROVIDER` | — | `international` or `jp`; empty selects `jp` for the region named jp, international otherwise | @@ -101,17 +101,22 @@ exist when they are given. | `[bootstrap] api` | `NNNOTES_BOOTSTRAP_API` | — | API root that serves the server list (`nnnotes servers`), same format | | `[client] version` | `NNNOTES_CLIENT_VERSION` | — | client version sent to the game's API, e.g. `1.0.1`; unset: the `versionName` of `[paths] apk` | | `[paths] catalog` | `NNNOTES_PATHS_CATALOG` | `--catalog` | a catalog `.bin` file to read instead of discovering/downloading a catalog; explicit files stay usable offline | -| `[paths] cache` | `NNNOTES_PATHS_CACHE` | `--cache` | cache directory (created when missing) | -| `[paths] store` | `NNNOTES_PATHS_STORE` | `--store` | store directory of the asset export ([assets.md](assets.md)); unset: `<[paths] cache>/store` | +| `[paths] depot` | `NNNOTES_PATHS_DEPOT` | `--depot` | depot directory (created when missing): the downloaded game files, the results of the stages, catalog versions and runs ([commands.md](commands.md#downloads), [contracts.md](contracts.md#depot)) | | `[paths] master` | `NNNOTES_PATHS_MASTER` | `--master` | decoded master data directory, one `.json` per table; the flag overrides `[servers.] master` | -| `[paths] apk` | `NNNOTES_PATHS_APK` | `--apk` | the game's `base.apk` (the story export reads the CRI Lips library from it, or from `split_config.arm64_v8a.apk` next to it) | +| `[paths] apk` | `NNNOTES_PATHS_APK` | `--apk` | the game's `base.apk` (the story export reads the CRI Lips library from it, or from `split_config.arm64_v8a.apk` next to it; the asset export its built-in Unity files) | | `[paths] ffmpeg` | `NNNOTES_PATHS_FFMPEG` | `--ffmpeg` | `ffmpeg` executable; unset: `ffmpeg` on `PATH` | | `[paths] vgmstream` | `NNNOTES_PATHS_VGMSTREAM` | `--vgmstream` | `vgmstream-cli` executable; unset: `vgmstream-cli` on `PATH` | | `[paths] node` | `NNNOTES_PATHS_NODE` | `--node` | Node.js executable; unset: `node` on `PATH` | | `[paths] player` | `NNNOTES_PATHS_PLAYER` | `--player` (of `site build`) | ournotes-player checkout (built) or installed package | | `[paths] fonts.` | `NNNOTES_PATHS_FONTS_` (e.g. `NNNOTES_PATHS_FONTS_ZH_HANT`) | `--font =PATH` (of `site build`) | font file (OpenType or TrueType) the story text of a language is drawn with (`--fonts open`); also read for the other story languages whose game fonts fall back to that language's font | | `[paths] fonts.emoji` | `NNNOTES_PATHS_FONTS_EMOJI` | `--font emoji=PATH` (of `site build`) | colour emoji font (PNG bitmap glyphs: CBDT or sbix) the stories' emoji sprites are drawn from (`--fonts open`); Noto Color Emoji (SIL Open Font License 1.1) is the tested font. Optional: without it the sprites keep their layout with empty glyphs, listed in `ui/fonts.json` `coverage.sprites.missing` | -| `[export] link` | `NNNOTES_EXPORT_LINK` | `--link` (of `export assets`) | how `export assets` makes the layout files from the store: `auto` (unset), `clone`, `hard` or `copy` ([assets.md](assets.md)) | +| `[depot] downloads` | `NNNOTES_DEPOT_DOWNLOADS` | — | download-cache budget, applied to finished conversion batches and when the last command ends: a size (`500MB`, `20GiB`; units B, KB, MB, GB, TB and KiB, MiB, GiB, TiB), `all`, or `0` (unset) ([commands.md](commands.md#downloads)) | +| `[depot] remote` | `NNNOTES_DEPOT_REMOTE` | — | a remote of the depot shared by the machines that use it: `s3:///` (S3-compatible object storage; `pip install 'nnnotes[remote]'`) or `file:///` ([contracts.md](contracts.md#depot-remote)) | +| `[depot] endpoint` | `NNNOTES_DEPOT_ENDPOINT` | — | endpoint URL of the S3-compatible object storage; unset: AWS S3 | +| `[depot] access_key_id` | `NNNOTES_DEPOT_ACCESS_KEY_ID` | — | access key id of the object storage; unset: boto3 looks for credentials itself (`AWS_ACCESS_KEY_ID`, `~/.aws`) | +| `[depot] secret_access_key` | `NNNOTES_DEPOT_SECRET_ACCESS_KEY` | — | secret access key of the object storage | +| `[depot] signing_region` | `NNNOTES_DEPOT_SIGNING_REGION` | — | region the requests to the object storage are signed for; unset: `us-east-1` | +| `[export] link` | `NNNOTES_EXPORT_LINK` | `--link` (of `export assets`) | how `export assets` makes the layout files from the depot: `auto` (unset), `clone`, `hard` or `copy` ([assets.md](assets.md)) | Hex values are case-insensitive; a `0x` prefix and surrounding whitespace are ignored. All values are TOML strings except `languages` (array of strings). @@ -136,36 +141,36 @@ nnnotes --region tw --catalog-version 1.0.0.201 export live2d MODEL_ID -o out/mo ``` Alternatively set `NNNOTES_SERVERS_TW_CATALOG_VERSION` in the build environment; workers inherit this pin. -`--catalog-version` selects a remote resource release; `--stored-catalog LABEL|SHA` of `export assets` and `store -plan` selects a catalog version of the store. To inspect the historical main catalog, explicitly pin `main`. -Versioned caches are isolated by CDN root and resource version; a cached pin needs the CDN setting to identify -its directory but performs no API/catalog download. A missing versioned file never falls back to main. +`--catalog-version` selects a remote resource release; `--stored-catalog LABEL|SHA` of `export assets` and `depot +plan` selects a catalog version of the depot. To inspect the historical main catalog, explicitly pin `main`. +Versioned downloads are isolated by CDN root and resource version; a pin whose catalog is downloaded needs the CDN +setting to identify its directory but performs no API/catalog download. A missing versioned file never falls back to main. ## What each command needs -"Catalog" below means: `[paths] cache`; either `[paths] catalog` or `[catalog] language` (the catalog is then -downloaded into the cache on first use); and, for what must be downloaded, `[catalog] region` and that region's +"Catalog" below means: `[paths] depot`; either `[paths] catalog` or `[catalog] language` (the catalog is then +downloaded into the depot on first use); and, for what must be downloaded, `[catalog] region` and that region's `cdn`, `[bundle] key` and `nonce_seed`. `[paths] apk` is optional for the catalog: when it is set, the APK's own catalog and bundles are merged in, and bundles that ship inside the APK can only be read with it. "Master data" means the decoded master data directory of the region: the `--master` flag, else `[servers.] master` of `[catalog] region`, else `[paths] master`. -`--fonts game` (`export story`, `export live`, `site build`) also needs the optional `fonts` dependencies (`pip install 'nnnotes[fonts]'`), +`--fonts game` (`site build`) also needs the optional `fonts` dependencies (`pip install 'nnnotes[fonts]'`), and so does `site build --story` / `--all-stories` with either font source (it generates the TextMesh Pro font assets of the story text); it is not a setting. | Command | Settings | |---|---| -| `catalog keys` | `[paths] cache`; `[paths] catalog`, or `[catalog] language` (plus region and `cdn` while the catalog is not in the cache); no bundle key | -| `catalog browse` | `[paths] cache`, `[bundle] key` + `nonce_seed`, and per region `cdn` + `languages` (`name` optional) | +| `catalog keys` | `[paths] depot`; `[paths] catalog`, or `[catalog] language` (plus region and `cdn` while the catalog is not downloaded); no bundle key | +| `catalog browse` | `[paths] depot`, `[bundle] key` + `nonce_seed`, and per region `cdn` + `languages` (`name` optional) | | `bundle pull` | catalog | | `master decode` | `[master] key` + `iv` | | `master download` | `[catalog] region` and that region's `cdn`; `--latest` also that region's `api` and the client version | | `master version` | `[catalog] region` and that region's `api`; the client version: `[client] version` or `[paths] apk` | | `servers` | `[bootstrap] api`; the client version: `[client] version` or `[paths] apk` | | `export episode` | catalog, master data | -| `export story` | catalog, `[catalog] language`, master data, `[paths] apk`, `ffmpeg` (audio and videos), `vgmstream` (not with `--no-audio`) | +| `export story` | catalog, master data, `[paths] apk`, `vgmstream`, `ffmpeg` | | `export live2d` | catalog, `[paths] apk` | | `export spot` | catalog, master data, `[paths] apk` | | `export room` | catalog | @@ -173,19 +178,20 @@ story text); it is not a setting. | `export audio` | catalog, `[paths] apk`, `vgmstream`, `ffmpeg` (not for `--format wav`) | | `apk hca-key` | `[paths] apk` | | `apk settings` | `[paths] apk` | -| `export live` | catalog, `[catalog] language`, master data, `[paths] apk`, `vgmstream`, `ffmpeg` | -| `site build --pair` / `--all` | as `export live`, plus `[paths] player`, `node`; with `--site-region` / `--all-regions` each region's `[servers.]` table (its `cdn` for what must be downloaded) and master data (`[servers.] master`; `[paths] master` for at most one region) | +| `export live` | catalog, master data, `[paths] apk`, `vgmstream`, `ffmpeg` | +| `site build --pair` / `--all` | as `export live`, plus `[catalog] language`, `[paths] player`, `node`; with `--site-region` / `--all-regions` each region's `[servers.]` table (its `cdn` for what must be downloaded) and master data (`[servers.] master`; `[paths] master` for at most one region) | | `site build --live2d` / `--all-live2d` | catalog (bundles from the CDN of the site's first region), `[paths] apk`, `[paths] player`; not `node`; master data only for the model names (optional: without it `models.json` has no names) | | `site build --player-only` / `--reingest-json` | `[paths] player` | | `music-data` | catalog, `[master] key` + `iv` (not with `--decoded-master`); `--master-files` also `[catalog] region`; `--apk-master` also `[paths] apk`; `--decoded-master` also `[catalog] region` and master data with its `MasterManifest.json`; `[paths] apk` (optional otherwise) for the client version | -| `export assets`, `store plan` | the store (`[paths] store` or `[paths] cache`); catalog (bundles are fetched into the cache); `[paths] apk` for the bundles inside the APK (without it they are reported as `source.absent`); master data for `--views`; with `--stored-catalog` a catalog version of the store instead of the current catalog | -| `store run` | the store; `[paths] cache` for inputs located in the cache; `--fetch` also what fetching needs (region, `cdn`, bundle key, `[paths] apk`) | -| `catalog versions` / `import` / `diff`, `store verify` | the store; `import` reads the APK's catalog from `[paths] apk` when it is set | -| `catalog fetch` | the store, `[catalog] region` and `language`, that region's `cdn`; its `api` (optional) for the resource version label | +| `export assets`, `depot plan` | catalog (bundles are fetched into the depot); `[paths] apk` for the bundles inside the APK (without it they are reported as `source.absent`) and its built-in Unity files ([stages.md](stages.md#the-apks-built-in-files)); master data for `--views`; with `--stored-catalog` a catalog version of the depot instead of the current catalog | +| `depot run` | the depot; `--fetch` also what fetching needs (region, `cdn`, bundle key, `[paths] apk`) | +| `catalog versions` / `import` / `diff`, `depot verify` | the depot; `import` reads the APK's catalog from `[paths] apk` when it is set | +| `catalog fetch` | the depot, `[catalog] region` and `language`, that region's `cdn`; its `api` (optional) for the resource version label | -The region, its `cdn`, `[bundle] key` and `nonce_seed` are read only when a file must be downloaded: when the -catalog and every bundle a command needs are in the cache, none of them is needed. A download that needs a missing -one stops the command with one line naming the file and the setting (see Errors). +The region, its `cdn`, `[bundle] key` and `nonce_seed` are read only when a file must be downloaded. A product +request can read a downloaded catalog and its bundle files without these settings. Use `[depot] downloads = all` +for an offline sequence of requests; a bounded budget can release a bundle that a later request needs again. +A download that needs a missing setting stops the command with one line naming the file and setting (see Errors). ## Errors @@ -194,7 +200,7 @@ the setting, its environment variable and its flag where there is one; it never ``` nnnotes: setting catalog.region is not set: give it as `region` in the [catalog] table of the config file, the environment variable NNNOTES_CATALOG_REGION or --region -nnnotes: .bundle is not in the cache: setting bundle.key is not set: give it as `key` in the [bundle] table of the config file, the environment variable NNNOTES_BUNDLE_KEY +nnnotes: .bundle is not downloaded: setting bundle.key is not set: give it as `key` in the [bundle] table of the config file, the environment variable NNNOTES_BUNDLE_KEY nnnotes: setting bundle.key: must be 16 bytes (32 hex digits) nnnotes: setting paths.apk: file not found nnnotes: ffmpeg not found on PATH: give its path as `ffmpeg` in the [paths] table of the config file, the environment variable NNNOTES_PATHS_FFMPEG or --ffmpeg @@ -230,7 +236,7 @@ nnnotes: game API call Version to [servers.tw] api failed: UNAVAILABLE (server u can hold several alternatives separated by `|`, and any one of them can be used as `cdn` / `api`. - **Client version**: the version of your game client (`versionName` of its `base.apk`). The game's API rejects a version older than the one it accepts (`game error code CLIENT_UPDATE_REQUIRED`). -- **`[paths] apk`**: the `base.apk` of your own installation of the game. `apk settings`, `export story`, `export live` and `site build` +- **`[paths] apk`**: the `base.apk` of your own installation of the game. `apk settings`, `export story` and `site build` read MonoBehaviours of its boot data with type trees that ship with nnnotes, one set per Unity version (currently game version 1.0.1, Unity 6000.3.12f1). With an APK whose classes do not match them, these commands stop with exit status 2 and a line naming the class, the game version and the Unity version. diff --git a/docs/contracts.md b/docs/contracts.md index c9e0f2d..9c47051 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -11,12 +11,26 @@ Every document names its format in a `schema` field (`nnnotes.result/1`). Within added, never renamed, removed or given another meaning; readers ignore fields they do not know. A breaking change is a new major version (`nnnotes.result/2`). +Episode font data uses the `format` field `nnnotes.story-fonts/1`, described in +[story-fonts.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-fonts.schema.json) +and [story products](products.md#stories). + +Live start UI data uses the `format` field `nnnotes.live-ui/1`, described in +[live-ui.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-ui.schema.json) +and [live start UI products](products.md#live-start-ui). + +Overlay story products use `nnnotes.story-host/1` and `nnnotes.story-simple-ui/1` in their `format` fields: +[story-host.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-host.schema.json) +and [story-simple-ui.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-simple-ui.schema.json). + Stages carry their own version. A change of what a stage writes for the same inputs is a new stage version, which changes the key of every task of that stage (golden tests fail when a stage's output changes while its version stays). ## canon/1 +The canonical JSON (`nnnotes.core.canon`) of every document nnnotes writes: + - UTF-8, LF line endings, one trailing newline, non-ASCII characters as they are. - Contract documents (everything on this page): object keys sorted, indent 1. - Object documents (the JSON of one Unity object): the object's own field order, indent 1. @@ -34,14 +48,15 @@ stays). | object | `:` | `CAB-2f1e…:-4153…`, `unity default resources:10` | | artifact of an object | `#` | `CAB-2f1e…:-4153…#image` | | artifact of no object | `:#` | `cri.audio:#bgm_x.flac` | -| task | `:` | `unity.export:membercard_assets_membercard_100101` | +| task | `:` | `unity.census:membercard_assets_membercard_100101`, `unity.export:membercard_assets_membercard_100101:-4153…` | | stable bundle name | the bundle file name without `_<32 hex>` and `.bundle` | `membercard_assets_membercard_100101` | | stable address | `/` (`[]` for a sub-object), else `:` | `membercard_assets_membercard_100101/assets/…/member_full.png` | Stage names contain no `:` or `#`; an artifact's owner contains no `#` (its role may). A bundle's serialized file names change whenever its content changes, while its path ids stay: an object id names one content version of an object, and the stable address finds the same object in another catalog version. Bundle stages use the stable -bundle name as their subject, so a task keeps its id across versions while its key follows the content. When two +bundle name as their subject and object stages the `:` address of their object, so a task +keeps its id across versions while its key follows the content. When two bundles of a catalog have the same stable name, both keep their full file names. ## Task: nnnotes.task/1 @@ -50,13 +65,14 @@ One stage applied to one subject, with everything needed to run it ([task.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/task.schema.json)): ```json -{"schema": "nnnotes.task/1", "id": "unity.export:membercard_assets_membercard_100101", - "stage": {"name": "unity.export", "version": 1}, - "params": {"png": {"encoder": "pillow", "level": 6}, "blobMin": 4096}, +{"schema": "nnnotes.task/1", "id": "unity.export:membercard_assets_membercard_100101:-4153…", + "stage": {"name": "unity.export", "version": 2}, + "params": {"blobMin": 4096, "png": {"level": 6}}, "atoms": {"png.encode": "pillow-12.3.0/1", "texture.decode": "unitypy-1.25.3/1"}, "inputs": [{"role": "bundle", "sha256": "…", "size": 123456, "name": "membercard_…_3fa…c2.bundle", - "locators": [{"kind": "store"}, {"kind": "cache", "path": "bundles/membercard_…_3fa…c2.bundle"}]}], - "context": {"scripts": {"CAB-…:12345": "Assembly-CSharp|Fwk.Sound|SplitAcbData"}}, + "locators": [{"kind": "depot"}, {"kind": "download", "path": "bundles/membercard_…_3fa…c2.bundle"}]}], + "context": {"unit": "CAB-2f1e…:-4153…", "bundle": "membercard_assets_membercard_100101", + "scripts": {"CAB-…:12345": "Assembly-CSharp|Fwk.Sound|SplitAcbData"}}, "key": "…", "cost": {"cpuSeconds": 1.2, "peakBytes": 450000000}} ``` @@ -83,10 +99,10 @@ Locator kinds, tried in order to find an input's bytes (each checked against the | Kind | Where | |---|---| -| `store` | the store's `cas/` | -| `cache` | `path` relative to the cache directory | +| `depot` | the depot's `cas/` | +| `download` | `path` relative to the depot's `downloads/` | | `file` | an absolute `path` | -| `catalog` | fetched through a catalog version of the store (`catalog`: its version id, `location`: the location id in its index) | +| `catalog` | fetched through a catalog version of the depot (`catalog`: its version id, `location`: the location id in its index) | ## Artifact record: nnnotes.artifact/1 @@ -95,7 +111,7 @@ One output file ([artifact.schema.json](https://github.com/MetaSekaiLab/nnnotes/ ```json {"id": "CAB-…:-4153…#image", "content": {"sha256": "…", "size": 1234, "mediaType": "image/png", "ext": "png"}, - "provenance": {"stage": {"name": "unity.export", "version": 1, "params": ""}, + "provenance": {"stage": {"name": "unity.export", "version": 2, "params": ""}, "atoms": {"texture.decode": "…", "png.encode": "…"}, "inputs": [{"role": "bundle", "sha256": "…"}], "object": {"file": "CAB-…", "pathId": -4153, "classId": 28, "class": "Texture2D", @@ -107,7 +123,8 @@ One output file ([artifact.schema.json](https://github.com/MetaSekaiLab/nnnotes/ `ext` is lower-case letters and digits. The bytes are stored once, by content id, whatever their names. Roles: an object's primary artifact is the one whose role comes first in `image`, `mesh`, `audio`, `video`, `font`, -`data`, `prefab`, `json` (then by role name); its other artifacts (`meta`, `blob:`, …) are secondary. +`data`, `json` (then by role name); its other artifacts (`json` beside an image, `blob:`, …) are +secondary. ## Result: nnnotes.result/1 @@ -118,7 +135,7 @@ The result of a task, written after the bytes of all its artifacts are stored: i {"schema": "nnnotes.result/1", "key": "…", "keyParts": {…}, "status": "ok", "artifacts": [], "items": [{"object": "CAB-…:1", "class": "Texture2D", "status": "exported", "artifacts": ["CAB-…:1#image"]}, - {"object": "CAB-…:2", "class": "Transform", "status": "contained", "in": "CAB-…:9#prefab"}, + {"object": "CAB-…:2", "class": "Transform", "status": "contained", "in": "CAB-…:9#json"}, {"object": "CAB-…:3", "class": "Texture2D", "status": "unsupported", "reason": {"code": "empty.texture", "message": "…"}}]} ``` @@ -142,7 +159,7 @@ stage, an input that cannot be read, a worker process that dies) leaves no resul What one run covered ([run.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/run.schema.json)): the context (region, language, the catalog version, and the master data its views read: the version named by `MasterManifest.json` and the sha256 of each table), the selection, the normalized parameters per stage, the stage versions, every task with its -key (`null` when it could not be described) and status (`hit`: the result was in the store, `ran`, `failed`), the +key (`null` when it could not be described) and status (`hit`: the result was in the depot, `ran`, `failed`), the sha256 of each layout manifest written, and a summary (task and item counts). The run id is the key hash of the context, selection, params, stages, the tasks' ids and keys, and the layouts, so a run with the same inputs has the same id whether its tasks ran or hit. The run log (`runs/.log.jsonl`) holds what is not deterministic: @@ -181,11 +198,11 @@ task id in the previous run manifest: | `params-changed` | run | a parameter changed (`path`, `old`, `new`) | | `context-changed` | run | a consumed context entry changed (`entry`) | | `previous-failed` | run | the task failed in the previous run | -| `result-missing` | run | same key as before, but its result is not in the store | +| `result-missing` | run | same key as before, but its result is not in the depot | | `key-changed` | run | the key changed for none of the reasons above | | `forced` | run | asked to run again | | `unchanged` | hit | same key as in the previous run | -| `cached` | hit | the result is in the store from another run | +| `cached` | hit | the result is in the depot from another run | | `pending` | unknown | the task reads the result of `task`, which would run first (`fetch:`: an input file whose content is not known yet) | ## Layout manifest: nnnotes.layout/1 @@ -197,6 +214,17 @@ bytes nor keys; the file names of `original` and `cas` both follow from the reco `original` manifest alone. Documents derived for `original` from records and paths (the views with the files of their objects) are the exception: `original` has the derived documents, `cas` the artifacts they are made from. +Task-description layouts use the ordinary layout contract. Each task file entry may include `inputs`, an array of +content records (`sha256`, `size`) for the task's input dependencies. Collection follows those references as well as +the task-description file itself. + +## Product manifest: nnnotes.product/1 + +A product layout writes, for each product of `pipeline.assemble`, a manifest +([product.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/product.schema.json)): the product's +files and the files of the objects it uses, each a content-addressed `assets/.`. The product's object +table is `nnnotes.objects/1`. See [products](products.md). + ## Reports - Coverage, `nnnotes.coverage/1` ([coverage.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/coverage.schema.json)): per class the census @@ -215,14 +243,14 @@ Documents written by particular stages (artifacts of no object: their ids are `< | Document | Stage | Schema | |---|---|---| | `nnnotes.catalog-index/1` | `catalog.index` | [catalog-index.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/catalog-index.schema.json) | -| `nnnotes.census/1` | `unity.census` | [census.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/census.schema.json) | +| `nnnotes.census/2` | `unity.census` | [census.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/census.schema.json) | | `nnnotes.scripts/1` | `link.scripts` | [scripts.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/scripts.schema.json) | -| `nnnotes.addresses/1` | `link.addresses` | [addresses.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/addresses.schema.json) | -| `nnnotes.artifacts/1` | `link.artifacts` | [artifacts.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/artifacts.schema.json) | +| `nnnotes.addresses/3` | `link.addresses` | [addresses.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/addresses.schema.json) | | `nnnotes.view/1` | `view.` | [view.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/view.schema.json) | +| `nnnotes.objects/1` | `pipeline.assemble` (a product's object table) | [objects.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/objects.schema.json) | -The catalog versions of a store, `nnnotes.catalogs/1` ([catalogs.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/catalogs.schema.json), -`/catalogs/index.json`), and the difference between two of them, `nnnotes.catalog-diff/1` +The catalog versions of a depot, `nnnotes.catalogs/1` ([catalogs.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/catalogs.schema.json), +`/catalogs/index.json`), and the difference between two of them, `nnnotes.catalog-diff/1` ([catalog-diff.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/catalog-diff.schema.json), `nnnotes catalog diff --json`), are written by the commands. @@ -230,23 +258,102 @@ The music data file of `nnnotes music-data`, `nnnotes.music-data/2` ([music-data.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/music-data.schema.json)), names its format in a `format` field; it is described in [music-data.md](music-data.md). -## Store +## Depot -The store is a directory, and the cache of all stage work: +The depot is a directory: the downloaded game files, and the results of all stage work by content: ``` -/cas/sha256/ab/ bytes by content id, written once (no extension: records carry it) -/ac/ab/.json results by task key (written after the bytes they name) -/inputs/.jsonl input identities: name -> sha256, size, with the path, size and mtime of the - file that was hashed (bundles.jsonl, raw.jsonl, files.jsonl) -/catalogs/ versioned catalogs -/runs/.json run manifests -/runs/.log.jsonl the log of the run's last execution -/runs/selections/ the id of the latest run of a selection (key hash of the selection list) -/costs/.json the measured cost of a task's last run +/downloads/ the game files as downloaded, under their own names; the bundles and raw files + are kept up to `[depot] downloads` ([commands.md](commands.md#downloads)) +/cas/sha256/ab/ bytes by content id, written once (no extension: records carry it) +/ac/ab/.json results by task key (written after the bytes they name) +/inputs//ab/.json + the identity recorded for an input name (kind bundles, raw or files): + {"name", "sha256", "size"}; digest: the sha256 of the name +/inputs/paths/ab/.json + the identity of a file as last hashed, with its path, size and mtime (a file + seen unchanged is not hashed again); digest: the sha256 of the path +/catalogs/ versioned catalogs +/runs/.json run manifests +/runs/.log.jsonl the log of the run's last execution +/runs/selections/ the id of the latest run of a selection (key hash of the selection list) +/costs/.json the measured cost of a task's last run +/leases/.json one per running command: {"host", "pid", "started"} +/leases/.exclusive held while a command runs alone (trimming the downloads, `depot gc`) ``` Every file is written to a temporary name and renamed into place, so concurrent writers never see or leave a -partial file and an interrupted task leaves no result. `cas/` and `ac/` depend only on the tasks run: two runs of the -same tasks, with any number of workers, leave identical trees. `inputs/`, `runs/*.log.jsonl` and `costs/` are -local and not deterministic. +partial file and an interrupted task leaves no result; a record that changes is one file, replaced whole. `cas/` and +`ac/` depend only on the tasks run: two runs of the same tasks, with any number of workers, leave identical trees; +`inputs/`, `runs/`, `costs/` and `leases/` are not deterministic. + +### Depot remote + +`[depot] remote` gives the depot a remote shared by the machines that use it: `s3:///` in +S3-compatible object storage (`pip install 'nnnotes[remote]'`; path-style requests, so stores other than AWS answer +them) or `file:///`. The remote holds the same keys as the depot directory, below its prefix: + +``` +cas/sha256/ab/ contents +ac/ab/.json results +inputs//ab/.json input identities by name +runs/.json run manifests +runs/selections/ the id of the latest run of a selection +catalogs/versions/.json one catalog version and its labels +catalogs/.bin catalog files +``` + +The depot directory is the remote's cache. A content, result, input identity or run the depot lacks is read from the +remote and kept; a content read from the remote is checked against its content id. What the depot adds goes to the +remote as well: the bytes a result names before the result, a run manifest before the pointer to it. A result that +differs from the remote's result of the same key is a conflict, as in the depot itself. The latest run of a selection +is the one any machine wrote last, and the results of its tasks are known to be on the remote without asking for each +of them. Downloads, file identities by path (`inputs/paths/`), measured costs, run logs and leases +stay in the depot directory. + +Catalog discovery lists the version records and merges them into the local catalog index. Catalog bytes are fetched +on demand and verified by size and SHA-256 before use. Each import publishes its catalog files before its version +record. `--stored-catalog` can select a version held by the remote on a fresh depot. Import sequences order the +versions, with version ids breaking ties between independent imports. + +Object reads and writes name individual keys. Catalog discovery and `depot gc --remote` use listings. + +## UI contracts + +`nnnotes.ui-pack/1` describes a UI product's root objects and animation bindings. `nnnotes.ui-library/1` indexes its selected keys, referenced prefabs and controllers. Both use the shared `nnnotes.product/1` object storage contract; see [UI libraries](ui.md). + +- [ui-pack.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/ui-pack.schema.json) +- [ui-library.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/ui-library.schema.json) + +## Site catalog + +[`site.schema.json`](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/site.schema.json) describes `nnnotes.site/1`. A named site stores its complete region-topic catalog in `site.json`. Each topic names an immutable product manifest and the metadata of its listing entry. Partial selections retain other topics; complete selections replace the requested region and product kind. The successful run points at the complete site layout so depot collection retains every published dependency. + +## Product document schemas + +The fixed product documents have the following schemas, also available through `nnnotes.schemas.load`. + +| Schema | Document | +|---|---| +| [episode](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/episode.schema.json) | Story episode commands and resource rows | +| [model-json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/model-json.schema.json) | Live2D model objects, resources and animation bindings | +| [prefab](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/prefab.schema.json) | Prefab hierarchy nodes, transforms and components | +| [live](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live.schema.json) | Live entry and shared product parts | +| [live-scene](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-scene.schema.json) | Live scene, lane resources and bands | +| [live-notes](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-notes.schema.json) | Note prefabs and shared animation bindings | +| [live-noteskin](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-noteskin.schema.json) | Note skin sprites and animation bindings | +| [live-noteeffect](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-noteeffect.schema.json) | Note effect prefabs and animation bindings | +| [live-music](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-music.schema.json) | Music layers and cue metadata | +| [live-sounds](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-sounds.schema.json) | Sound groups, categories and routing | +| [story](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story.schema.json) | Story entry and shared product parts | +| [story-scene](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-scene.schema.json) | Story scene objects, settings and graphics | +| [story-ui](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-ui.schema.json) | Localized story UI objects and text settings | +| [story-asset](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-asset.schema.json) | Story media prefab objects and animation bindings | + +### Scene and media document schemas + +- [room](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/room.schema.json) +- [spot](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/spot.schema.json) +- [story-video](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-video.schema.json) +- [story-sound](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-sound.schema.json) +- [crilips](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/crilips.schema.json) diff --git a/docs/depot.md b/docs/depot.md new file mode 100644 index 0000000..40f67aa --- /dev/null +++ b/docs/depot.md @@ -0,0 +1,53 @@ +# Depot reference + +The depot stores task results and their content-addressed files. Set `[paths] depot` to its directory. +Commands share it across executions; an output layout is a separately materialized selection of its results. + +| Directory | Contents | +|---|---| +| `cas/sha256/` | File bytes indexed by SHA-256 | +| `ac/` | Task result documents indexed by task key | +| `runs/` | Run manifests, logs and latest-selection pointers | +| `catalogs/` | Catalog versions, labels and verified catalog bytes | +| `inputs/` | File identity records and named input identities | +| `costs/` | Measured task costs used by scheduling | +| `downloads/` | Recoverable source downloads | +| `leases/` | Active command leases and the exclusive collection lock | + +## Remote storage + +Set `[depot] remote` to a `file:///...` directory or an `s3://bucket/prefix` URL. S3 storage requires +the `remote` extra. See [configuration](configuration.md) for endpoint and credential settings. + +Results and content are fetched when needed. Publishing a run first uploads its completed results, available +stored inputs and layout contents. This also shares results computed before remote storage was configured. +A run pointer alone is not evidence that a result exists: planning checks the result itself. +S3 listings follow continuation tokens and deletion requests use batches of at most 1,000 objects. +A per-object deletion error stops collection and reports failure, even when the service returns HTTP success. +Catalog discovery merges version metadata and shares locally imported versions with their catalog bytes. + +`depot verify` checks local content and results. When a local result refers to content held only by the remote, +verification checks that remote object without adding it to the local cache. Full verification checks hashes; +`--quick` checks sizes. + +## Retention + +Each command using the depot records its outcome and the stored content and task results it accessed. These +command runs are independent of product and site selections, so they retain inputs without replacing those +selections' latest layout. Commands that only use downloads or catalog files still leave an execution record; +those source directories are outside CAS collection. Dry runs leave no execution record. + +`depot gc` retains the results, stored inputs and layouts reachable from kept runs. By default all runs are kept; +`--keep N` retains the latest N runs per selection and always the selection's latest run. + +Results and content younger than `--grace SECONDS` are also retained. The default is 86400 seconds, in both local +and remote storage. A recent result protects even older inputs and artifacts, covering the interval before its +run record is written. `--grace 0` removes this time-based protection. `--remote` additionally collects remote +storage from its own run records. `--dry-run` only counts removals and does not fetch remote content into the cache. + +Collection requires an exclusive lease. A command waiting for an exclusive holder reports the wait and fails +after 30 seconds if the holder is still active; it leaves that holder's lock intact. Download retention is +separate: `[depot] downloads` sets its byte budget, with `all` keeping downloads. + +For task planning and execution, see [asset commands](assets.md). For identities and document shapes, see +[contracts](contracts.md) and [formats](formats.md). diff --git a/docs/determinism.md b/docs/determinism.md index 550828c..1effd04 100644 --- a/docs/determinism.md +++ b/docs/determinism.md @@ -19,7 +19,7 @@ fixes the bytes, what can change them, and how the tests hold it. textures, shaders and audio are converted from the stored data with fixed encoder settings. - **No clocks.** No output holds the time of a run or a path of the machine it ran on. A result of the asset export holds no input name, locator, timestamp or traceback; the measured cost of a task (CPU and wall seconds, memory) is - kept in the store's cost report, apart from the results. + kept in the depot's cost report, apart from the results. ## What can change the bytes @@ -32,19 +32,25 @@ Another version of an encoder can write the same content as other bytes: while lossy formats depend on the encoder version. - zlib and brotli encode the compressed assets of a site. -So the guarantee is per installation: pin the versions to reproduce a result exactly. The store keeps this visible: +So the guarantee is per installation: pin the versions to reproduce a result exactly. The depot keeps this visible: a task's key includes the implementation ids of the atoms it uses, with the versions of the libraries they call, so another installation computes other keys instead of mixing results. ## How it is tested +PNG and glyph task identities include `Pillow.zlib`, the version of Pillow's linked zlib codec and its zlib-ng +backend when present. This distinguishes native encoders even when the Pillow package version is the same. +Golden records that use those codecs also record this native identity with their library versions. + - **Golden records.** `tests/golden/.json` records, for each stage version, the libraries that shape its bytes and, for every synthetic fixture, the task key and the SHA-256 of the canonical result. A change of a stage's output without a new stage version fails the test. The CI job that checks the records installs the exact library versions of `.github/goldens-constraints.txt` and sets `GOLDENS_STRICT=1`; elsewhere a record made with - other library versions is skipped. + other library versions is skipped. Every registered output stage and site stage has a record; the coverage + check includes the master-data view family. Product fixtures exercise shared objects, localized references, + open and game fonts, host state, media indexes and regional site aliases. - **Two runs, one result.** The tests run exports twice over synthetic inputs and compare the bytes, and compare - one worker with several: a store built with four workers equals one built with one, and parallel audio decoding + one worker with several: a depot built with four workers equals one built with one, and parallel audio decoding equals sequential decoding. - **Writer tests.** The JSON writer's tests cover the non-finite literals, the NaN error and the line endings. diff --git a/docs/export.md b/docs/export.md index 8251fdd..c5c72c5 100644 --- a/docs/export.md +++ b/docs/export.md @@ -1,7 +1,7 @@ # Export stories and lives The `export` commands each write one subject as a directory or file. They read the catalog, fetch and decrypt the -bundles they need into the cache, and need decoded master data where the subject is defined by it +bundles they need into the depot, and need decoded master data where the subject is defined by it ([what each command needs](configuration.md#what-each-command-needs)). To export every object of many bundles at once, use [`export assets`](assets.md) instead. @@ -12,17 +12,30 @@ Every command below prints a short summary; add `--json` for its report as JSON. ```sh nnnotes export episode 10462 -o out/episode_10462.json # the episode's commands, lines and resources -nnnotes export story 10462 -o out/stories/10462 # everything the episode plays +nnnotes export story 10462 --fonts game -o out/stories/10462 # the episode with the client's TMP fonts ``` -`export story` writes the episode, its decoded cue sheets, the stage scene, the story UI, frames, effects, stills, -chats and videos below `-o`, and its Live2D models below the models directory (`--models`, default `live2d/` next to -the story directory). A model directory that exists is used as it is, so exporting the stories of a season into -sibling directories exports each model once. +`export story` writes the story product of an episode and the products it plays with, as a product layout below `-o`: +the story index in `stories/.json`, the stage scene in `storyscenes/default.json`, each stage, frame, effect and +other asset in `storyassets/`, each Live2D model in `models/`, each cue sheet in `cuesheets/`, each video in +`videos/` and, when the episode uses them, the shared chat rows in `storychat/default.json` and the CRI Lips data in `crilips/default.json`. The files +themselves are under `assets/`. -- `--format flac|ogg|wav` and `--flac-level N` choose the audio format; `--no-audio` leaves the cue sheets undecoded. -- `--fonts game` also writes the game's fonts reduced to the characters shown (needs `pip install 'nnnotes[fonts]'`). -- `--force` exports the episode's models again. +`storyui/.json` and `storyfonts/_.json` provide the UI, text bindings and glyph pages for +`[catalog] language`. `--story-languages ja,en` selects several languages. `--fonts open` (the default) uses font +files from `[paths.fonts]` or repeatable `--font LANG=PATH` flags; supply any languages needed by the fallback chain. +Both font sources require the `fonts` extra. + +Overlay episodes also export their home or live-result host and the simple talk-window UI. Home hosts share the +spot's Spine files and room glTF products. Their area title and simple UI text use the episode's font product. + +Products that several stories share (the scene, assets, models and cue sheets) are placed once, so exporting several +stories into one directory keeps one copy of each. Every product goes through the depot, so a second export reuses +its results. + +- `--format NAME` chooses the web audio format of the cue sheets: `aac` (default), `opus`, `vorbis`, `mp3` or `flac`. +- `--flac-level N` sets the FLAC compression level (0 to 12, default 8) of the decoded sounds. +- `--dry-run` prints the plan and runs nothing. `MasterAdv` lists the episode ids; `nnnotes voices list --source story --episode ID` lists the voiced lines of one. Details: [export episode](commands.md#export-episode), [export story](commands.md#export-story). @@ -30,17 +43,22 @@ Details: [export episode](commands.md#export-episode), [export story](commands.m ## A live ```sh -nnnotes export live 100001 --difficulty expert -o out/lives/100001_expert +nnnotes export live 100001 --difficulty expert --fonts game -o out/lives/100001_expert ``` -One chart as the self-contained directory ournotes-player reads: the chart as shipped and its converted runtime -notes, the 3D scene, the note and effect assets, the BGM and sound effects with their routing, and the start canvas -in the configured language. +One chart and the products it plays with, in the layout ournotes-player reads: the chart as shipped and its +converted runtime notes, the 3D scene, the note highway with its skins and effect sets, the sound effects with their +routing, the BGM in a web format, and localized start-canvas text with glyph pages. - `--band BAND` or `--leader-card CARD_ID` picks the band of the background and start timeline (default: the band of the music's first vocal character). -- `--live-option NAME[=VALUES]` (repeatable) adds the files of other values of the live options, such as - `MirrorChart` or `NoteDesignId`; `--live-option all` adds every option with every value. +- `--live-option NAME[=VALUES]` (repeatable) adds the products of other values of the live options, such as + `NoteDesignId` or `NoteSePatternId`; `--live-option all` adds every option with every value. + +- `--live-languages ja,en` selects UI languages (default: catalog language; `all` selects all client languages). +- `--fonts game` uses the client's TMP fonts. The default, `--fonts open`, reads font files from `[paths.fonts]` + or repeatable `--font LANG=PATH` flags. Supply the languages used by the fallback chain. Both modes need the + `fonts` extra. Details: [export live](commands.md#export-live). @@ -51,19 +69,19 @@ nnnotes catalog keys --prefix Character/Live2D/ --limit 0 # every model key nnnotes export live2d adv_live2d_rana_003_casual_spring_01 -o out/live2d/rana ``` -`MODEL` is the model's key or its id, the last part of the key. The output is a Cubism runtime directory: moc3, atlas -pages, motions, expressions, physics, `model3.json` and the whole prefab as JSON. Details: -[export live2d](commands.md#export-live2d). +`MODEL` is the model's key or its id, the last part of the key. The output is the model's Cubism SDK files: moc3, +atlas pages, motions, expressions, physics and `model3.json`; `--player` writes the model the player reads instead. +Details: [export live2d](commands.md#export-live2d). ## A spot or a room ```sh nnnotes export spot SPOT_ID -o out/spots/SPOT_ID -nnnotes export room KEY -o out/rooms/room.glb # a background prefab key +nnnotes export room KEY -o out/rooms/PLACE # a background key ``` -`export spot` writes the spot's master row with its names and tap-talk episodes, its Spine characters, its -background as binary glTF and the shaders of its bundles. `export room` writes one background prefab as binary glTF +`export spot` writes the spot's master row with its names and tap-talk episodes, its tap targets, its Spine +characters and their files, and its background as binary glTF. `export room` writes one background as binary glTF with a summary next to it. Details: [export spot](commands.md#export-spot), [export room](commands.md#export-room). ## Shaders, audio and the UI library diff --git a/docs/formats.md b/docs/formats.md new file mode 100644 index 0000000..1a67c49 --- /dev/null +++ b/docs/formats.md @@ -0,0 +1,37 @@ +# Format reference + +nnnotes publishes versioned JSON documents. The `schema` or `format` field identifies the document contract; +installed schemas are available through `nnnotes.schemas`. See the [schema index](schemas.md) for individual files. + +| Document | Identity | Purpose | +|---|---|---| +| Task | `nnnotes.task/1` | Stage, version, parameters, inputs and task key | +| Result | `nnnotes.result/1` | Artifacts and item outcomes of a task | +| Run | `nnnotes.run/1` | Requested tasks, outcomes and retained layouts | +| Object table | `nnnotes.objects/1` | Converted objects, roles and reference closure | +| Product manifest | `nnnotes.product/1` | A product's files and the objects it uses | +| Layout | `nnnotes.layout/1` | Owned output paths and their stored content | + +## Objects and references + +Objects have stable addresses within their serialized file and are referred to by `:`. +`{"$ref": ""}` names an object in the product's object table. Its roles identify JSON, images, +shader programs and binary files. `{"$hex": "..."}` carries inline hexadecimal bytes; blob descriptors identify +separate binary roles. Full representations and examples are in [products](products.md) and [contracts](contracts.md). + +## Products + +A product manifest names its `product`, `subject`, `files` and `objects`. Files point to content-addressed assets +and record decoded sizes. Encoded files additionally record stored sizes; split JSON files list their parts. +The manifest's optional `root` locates these paths relative to the manifest URL. + +Story and live language parameters are part of their task identities. Their language-indexed part references +name the requested UI and font products. Site snapshots retain the selected available languages. + +A site's chart alias may additionally carry `musicId`, `difficulty`, `chart`, `regions`, `quality`, `flows`, +`audio` and `audioFormat`. The chart facts agree with `charts.json`; its byte count covers unique assets in the +product closure, including variants. The supported start flow is `direct`. With audio disabled, `audio` is false +and `audioFormat` is null. Standalone live products provide chart facts through their `live.json` and music part. + +For product composition see [products](products.md); for compressed files, regional aliases and publication +ordering see [site layout](site.md). The [depot reference](depot.md) describes storage and retention. diff --git a/docs/index.md b/docs/index.md index faf386f..73cdad5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,57 +1,41 @@ # nnnotes -nnnotes is an offline data toolkit for the game files of BanG Dream! Our Notes. It reads Addressables catalogs, -downloads and decrypts asset bundles, downloads and decodes master data, and exports stories, Live2D models, spots, -shaders, CRI audio and live charts as structured JSON and common file formats. `nnnotes site build` writes the static -site that [ournotes-player](https://github.com/empty-sekai/ournotes-player) plays, and `nnnotes music-data` one JSON -file with every song and chart. +Export your BanG Dream! Our Notes game files as structured products, media or a static player site. +nnnotes reads catalogs, bundles, APK resources and master data, then stores reusable conversion results in a +persistent depot. You supply the client files, keys and server configuration. -This is an unofficial fan project, not affiliated with the game's developer or operator. nnnotes contains no game -assets, keys or server addresses: you supply the game files, the keys and the server addresses in your own -configuration, and the exports stay in the local directories you choose. +## Start here -## Where to start +1. [Install and export a story](quickstart.md). +2. [Build and serve a player site](site.md) with ournotes-player 0.2.x. +3. [Share and retain depot results](depot.md), locally or through S3-compatible storage. -
+## Guides -- **[Quick start](quickstart.md)** +| Goal | Guide | +|---|---| +| Set input paths, regions and tools | [Configuration](configuration.md) | +| Export a story, live, model or other subject | [Exports](export.md) | +| Convert selected bundles and objects | [Asset export](assets.md) | +| Use Japanese client files | [Japanese release](jp.md) | +| Produce song and chart statistics | [Music data](music-data.md) | +| Check your own outputs | [Validation](validation.md) | - From an empty config file to an exported story, step by step. +## Reference -- **How-to guides** +[CLI](cli.md) · [Command reports](commands.md) · [Python API](api.md) · [Schemas](schemas.md) · +[Formats](formats.md) · [Products](products.md) · [Depot](depot.md) · [Contracts](contracts.md) · +[Stages](stages.md) · [Views](views.md) · [Replay data](replay.md) · [UI library](ui.md) - [Configure nnnotes](configuration.md), [export stories and lives](export.md), - [build an ournotes-player site](site.md), [export every asset](assets.md), - [the Japanese release](jp.md), [music data](music-data.md). +[Architecture](architecture.md) explains the conversion and assembly layers. +[Determinism](determinism.md) explains task identities and output reproducibility. -- **Reference** +## Requirements and versions - [Commands](cli.md), [command outputs](commands.md), [JSON schemas](schemas.md), [Python API](api.md), - [contracts](contracts.md), [stages](stages.md), [views](views.md), [replay data](replay.md), - [UI library](ui.md). +nnnotes 0.3.0 requires Python 3.11+. Optional extras provide fonts (`fonts`), S3 clients (`remote`) and +ournotes-sim 0.0.12 chart statistics (`deck`). Site builds require Node.js 22+ and a built ournotes-player 0.2.x +package. Audio and video conversion require vgmstream and FFmpeg. -- **Explanation** - - [Architecture](architecture.md), [determinism](determinism.md), [validation](validation.md). - -
- -## Install - -```sh -pip install nnnotes # Python 3.11+ -pip install 'nnnotes[fonts]' # the game's fonts (--fonts game) and the story text of site build -pip install 'nnnotes[deck]' # the deck model's chart statistics of music-data -``` - -Besides the package, nnnotes reads the game's `base.apk` and, for the commands that need it, decoded master data; -audio and video need [vgmstream](https://vgmstream.org/) and [FFmpeg](https://ffmpeg.org/), and `site build` also -Node.js 20+ and a built ournotes-player. [Configuration](configuration.md#what-each-command-needs) lists what each -command needs. - -## Versions - -Versions follow [Semantic Versioning](https://semver.org/). The interface a version describes is the command line -(commands, options, exit statuses and the `--json` reports), the configuration keys, the JSON schemas shipped in -`nnnotes.schemas` and the documented Python API. Before 1.0 a minor version can change any of them; the -[changelog](https://github.com/MetaSekaiLab/nnnotes/blob/main/CHANGELOG.md) names every change. +Versions cover the CLI, configuration, schemas and documented Python API. Before 1.0, minor versions may change +these contracts; see the [changelog](https://github.com/MetaSekaiLab/nnnotes/blob/main/CHANGELOG.md). +This unofficial fan project is MIT-licensed. Game resources remain subject to their owners' rights. diff --git a/docs/jp.md b/docs/jp.md index df5067e..ecccac6 100644 --- a/docs/jp.md +++ b/docs/jp.md @@ -25,7 +25,7 @@ apk = "/path/to/jp/base.apk" master = "/path/to/jp/master" [paths] -cache = "/path/to/cache" +depot = "/path/to/depot" ``` Keep `[bundle] key/nonce_seed` and `[master] key/iv` in your configuration. Master keys are unnecessary when @@ -65,7 +65,7 @@ nnnotes --region jp master version nnnotes --region jp master download --latest -o work/jp-master-bin nnnotes master decode work/jp-master-bin -o work/jp-master nnnotes --region jp catalog keys --prefix Live/MusicScore/ --limit 10 -nnnotes --region jp bundle pull Live/MusicScore/0001/0001_00 +nnnotes --region jp bundle pull Live/MusicScore/0001/0001_00 -o bundles nnnotes --region jp catalog fetch nnnotes --region jp export assets --select key:Image/Jacket/jacket_temporary --views none -o out/jp-sample nnnotes --region jp music-data --decoded-master --no-bgm -o out/jp-music-data.json @@ -79,17 +79,17 @@ the JP hash in `provenance.catalog.resourceHash`. JP catalogs are `catalog_main.bin` in a version/hash directory, without a language suffix. The HTTP payload can be gzip; nnnotes keeps its original bytes and SHA-256, and bounds decompression before parsing. Remote bundle and CRI locations use `{Fwk.Resource.RemoteAssetDir}`. These are indexed, resolved and downloaded through the selected -snapshot, including in `catalog browse`, the other `catalog` commands, `export assets`, `store plan` and -`store run`. +snapshot, including in `catalog browse`, the other `catalog` commands, `export assets`, `depot plan` and +`depot run`. -The cache lives under `/jp////`. It cannot reuse the -international catalog cache. Embedded files are further isolated under `apk//`, so an APK -update cannot reuse old local bundles when the CDN snapshot stays unchanged. Each cached `catalog_main.bin` has a `catalog_main.bin.source.json` with its SHA-256 +The downloads live under `/downloads/jp////`, apart from the +international downloads. Embedded files are further isolated under `apk//`, so an APK +update cannot reuse old local bundles when the CDN snapshot stays unchanged. Each downloaded `catalog_main.bin` has a `catalog_main.bin.source.json` with its SHA-256 and public source metadata. For an offline `--catalog` or `catalog import`, copy the pair together. Credentials are reacquired only when a missing file must be downloaded. Catalog database records include this source metadata, and JP catalog identity includes version/hash even when -the catalog bytes remain the same. Old international records retain their previous IDs. Cached historical files +the catalog bytes remain the same. Old international records retain their previous IDs. Downloaded historical files can be read offline; downloading a missing historical file fails if Version now selects a different snapshot. Refresh the catalog to use the new snapshot. Historical local bundles require the APK set matching the imported APK catalog. A JP master download likewise rejects a different current master snapshot. diff --git a/docs/music-data.md b/docs/music-data.md index dae9ac1..1361ce9 100644 --- a/docs/music-data.md +++ b/docs/music-data.md @@ -54,7 +54,7 @@ Each master data file is checked against the SHA-256 the manifest lists and deco With `--decoded-master` the tables are read as decoded, and the master data version and each file's SHA-256 are the manifest's: the decoded tables cannot be checked against the files as served, so the file records what the manifest lists (the same values as `--master-files` on those files). -Charts are read from the catalog of `[catalog] language` (bundles fetched into the cache as for every command); the +Charts are read from the catalog of `[catalog] language` (bundles fetched into the depot as for every command); the BGM length from the cue sheet's ACB (its `CueTable` and `WaveformTable`, no audio is decoded). `FILE` ending in `.gz` is written gzip-compressed. The command prints a summary (`songs`, `charts`, `deck`: the deck model's commit, `unplayable`, `bytes`, `sha256` of the file, ...). @@ -102,7 +102,7 @@ A **text** is an object with one string per language of `languages` (`{"ja": ... |---|---| | `region` | the region whose master data this is (a configured region name), or `embedded` for the APK's master data | | `client.versionName`, `client.versionCode` | the APK's version name and code (null without `[paths] apk`) | -| `catalog.resourceVersion` | the resource version used to select the downloaded catalog, or recorded for an explicit file in the catalog store (`nnnotes catalog fetch` / `import`); null when unknown | +| `catalog.resourceVersion` | the resource version used to select the downloaded catalog, or recorded for an explicit file in the catalog depot (`nnnotes catalog fetch` / `import`); null when unknown | | `catalog.sha256` | SHA-256 of the remote catalog file the charts were read with | | `master.source` | `api` (`--master-files`, `--decoded-master`: the region's files) or `embedded` (`--apk-master`) | | `master.version` | the `version` of the master data manifest | diff --git a/docs/products.md b/docs/products.md new file mode 100644 index 0000000..95597d3 --- /dev/null +++ b/docs/products.md @@ -0,0 +1,571 @@ +# Products + +A product is something a player or a site reads as a whole: a Live2D model, a story episode, a live. Products are +assembled from the results of `unity.export` ([stages](stages.md)), never from a bundle: by the time a product is +made, every object it uses has been converted once, and the product combines those conversions with what it adds +itself. + +## Product stages + +A product stage (`nnnotes.pipeline.assemble.ProductStage`) has one task per subject. Its task starts from root +objects: + +- the objects of the catalog keys the stage gives for the subject (`keys`), through the address table of + `link.addresses`; +- the objects of the Resources.Load paths it gives (`resources`): paths of the ResourceManager in the APK's built-in + files, such as the materials a runtime loads by name, through the same table and compared ignoring case; +- the objects of the built-in selectors it gives (`builtins`): objects of the APK's built-in files by class + (`GraphicsSettings`) or a MonoBehaviour by its script's class (`MonoBehaviour:`), such as settings assets + the game reads without a path, through the same table; +- the object ids the stage names besides (`root_objects`). + +A product stage may also read cue sheets: the catalog keys `cue_sheets` gives (each also one of its keys). A cue +sheet is the ACB its key loads, held in a bundle (the `acb` file of an object of the key) or a raw file of the +catalog that `cri.audio` decoded. The task's inputs then hold each ACB (`acb:`) and, as `sheets`, the files +`cri.audio` made of it (`{key: {acb, status, reason, files: {name: {sha256, size, ext}}}}`); it depends on those +`cri.audio` tasks, and a product file may be one of those files as stored, without copying its bytes. Movies work the +same way: the catalog keys `movies` gives, each the USM its key loads (the `usm` file of an object of the key, or a raw +file of the catalog that `cri.movie` decoded), as `usm:` and, in `movies`, the files `cri.movie` made of it with +their facts (`{key: {usm, status, reason, files: {name: {sha256, size, ext, facts}}}}`). A stage that +reads files of the APK names them in `APK`: inputs `apk:`, from the fact `apk`. + +All are read from the planning facts alone, so a command can ask for what its products need before anything runs: +`assemble.request(stages, products, env)` gives the fact `roots` (`{"keys", "resources", "builtins", "objects"}`), and +the request of `unity.export` then holds the units of those objects and every unit reachable from them through the +census `refs` ([units](stages.md#units)), and nothing else of their bundles. + +What a task reaches: the units holding its roots, then every unit that an item of a reached unit references (the +item's `refs`; a SpriteAtlas reached through a reference does not reach the sprites it packs), through the +`unity.export` results. A sprite whose atlas is in another bundle has its `image` from the +`sprite.crop` result of that atlas. A reached unit that the request did not export is an error of the request; an +object that a reached object references and no census holds is listed as `missing`. A subject without roots (a +product made from the APK alone) reaches nothing and reads no census. + +| Part of the task | | +|---|---| +| inputs | `objects`: the object table of what it reaches (`nnnotes.objects/1`); the stage's own inputs (master tables, data its parameters name) | +| depends | `link.addresses` (when it has keys, resource paths or built-in selectors), the `unity.export` and `sprite.crop` tasks whose results it read, the `cri.audio` tasks of its cue sheets | +| artifacts | `#`: the product's own files; `#objects`: the object table of the objects its files use (the whole table unless the stage narrows it to some objects, or to some files of an object: a shader's programs for one graphics API) | +| item | one for the subject: `exported`, or `unsupported` with a reason code | + +The key holds the object table, so a product is assembled again exactly when a file of an object it reaches +changes, or what it reaches changes. + +## Object table: nnnotes.objects/1 + +([objects.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/objects.schema.json)) + +```json +{"schema": "nnnotes.objects/1", + "roots": ["CAB-…:-4153…"], + "keys": {"Character/Live2D/…/model/…": ["CAB-…:-4153…"]}, + "objects": { + "CAB-…:-4153…": {"status": "exported", "class": "GameObject", "name": "model", + "files": {"json": {"sha256": "…", "size": 5120, "ext": "json"}}}, + "CAB-…:12": {"status": "contained", "class": "Transform", "in": "CAB-…:-4153…"}, + "CAB-…:40": {"status": "exported", "class": "Texture2D", "name": "texture_00", + "files": {"image": {"sha256": "…", "size": 812345, "ext": "png"}, + "json": {"sha256": "…", "size": 640, "ext": "json"}}}}, + "fenced": {}, + "missing": []} +``` + +`keys` gives the root objects of each catalog key of the product, so an assembler finds an object by the key the +game loads it by. Each object has the status of its item (the most severe when two stages made items for it), its +class and name where known, its files by role (the artifacts of its items), the object whose file holds it (`in`, a contained +object), or the reason code of a generic, unsupported or failed object. + +`fenced` names the objects that a reached object references across the font fence: the font bundles (the keys under +`EmbFont/` and `Font/`) are not followed from objects outside them, so each such reference is listed with the class and +name of the object it names, while the reference itself carries only its file and path id. A reference to an object of +the table is not listed. + +## Product layout + +A product layout places products for a player (`export assets --layout product`, `export live`, +`export live2d --player`): + +- every file of a product and every file of the objects of its object table at `assets/.` (a file + several products or objects share is written once); +- each product's manifest at `/.json` (the stage's `manifest(subject)`, by default + `/.json` with the subject escaped as one path segment). + +A command asks for products in one of two ways: `export assets --layout product` makes, with each product stage, the +products of the catalog keys the selected bundles hold (the stage's `select`; a stage that reads master data has none +without it); `export live`, `export live2d`, `export spot` and `export room` ask for the products of one subject. +`export live2d` without `--player`, `export spot` and `export room` place the files of their products at their own +paths instead (the `files` placement: the Cubism SDK files as a directory, a spot's files). + +## Product manifest: nnnotes.product/1 + +([product.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/product.schema.json)) + +```json +{"schema": "nnnotes.product/1", "product": "live2d.model", "subject": "…", + "files": {"model.json": {"asset": "assets/.json", "size": 2048}}, + "objects": { + "CAB-…:-4153…": {"class": "GameObject", "name": "model", + "files": {"json": {"asset": "assets/.json", "size": 5120}}}}} +``` + +`files` are the product's own files by path; `objects` are the objects of its object table that have files. An object +contained in another object's file (a Transform or a component in a prefab) is not listed: the prefab's JSON holds it +(its nodes and components carry their `pathId`). An asset path is relative to the directory above the manifest's directory, as +in the chart manifests of ournotes-player. A reference in an object's JSON (`{"$ref": {"file", "pathId"}}`) names +the object `:` of `objects`. + +## Live2D models + +Two product stages (`nnnotes.pipeline.live2d`) assemble a Live2D model from the objects its prefab reaches. Their +subjects are model keys, `Character/Live2D///model/`; a model's id is its ``. + +`live2d.model` is the model the player reads. Its roots are the model's GameObject (the object of its key) and the +materials the Cubism mask pass draws with, which the APK's built-in files hold at the `Resources.Load` paths +`Live2D/Cubism/Materials/Mask` and `Live2D/Cubism/Materials/MaskCulling`. Its manifest is `models/.json`, and its +one file `model.json` (format 3) names objects of its object table: + +| Field | | +|---|---| +| `name`, `key` | the model's id and key | +| `prefab` | the object id of the model's GameObject; its `json` is the hierarchy (prefab.json) | +| `moc` | the object id of its CubismMoc (the root's `CubismModel._moc`); the moc3 is its file `blob:_bytes` | +| `canvas` | the moc3's canvas: `pixelsPerUnit`, `originX`, `originY`, `width`, `height`, `mocVersion` | +| `textures` | the object ids of the atlas pages the drawables draw with (`CubismRenderer._mainTexture`), by name | +| `shaders` | `{shader name: object id}` of the shaders of the object table | +| `resources` | `{"cubismMask", "cubismMaskCulling"}`: the object ids of the two mask materials | +| `motionSync` | the root has the MotionSync controller and its CRI audio input | +| `bindings` | `{clip object id: [{path, attribute}]}`: the bindings of every AnimationClip of the table named in the model's hierarchy, played from its root (`nnnotes.unity.bindings`; `null` where no name, or several, fit) | + +Its object table holds every object the model reaches but the MonoScripts. Of a shader it keeps the parsed form +(`json`), the variant index (`index`) and the GLSL ES 3.00 programs (subshader 0, pass 0) the player draws the +drawables' materials with: for each material's keywords, alone and with `_ADDITIONAL_LIGHTS_VERTEX` (the keyword the +story renderer adds to a character draw), and the mask materials' when a drawable is masked (`CUBISM_MASK_ON`). + +`live2d.cubism` is the model as the Cubism SDK files other tools read (`export live2d`); its manifest is +`cubism/.json` and it has no objects: + +| File | From | +|---|---| +| `.moc3` | the CubismMoc's bytes | +| `textures/.png` | the atlas pages, by name (a name two pages share is numbered: `-2.png`) | +| `.physics3.json` | the root's `CubismPhysicsController._rig` (when it has sub-rigs) | +| `.exp3.json` | each CubismExpressionData of the table | +| `motions/.motion3.json` | each CubismFadeMotionData, with the sample rate of the clip of its name | +| `.model3.json` | the files, the model's fade times of each motion, and the EyeBlink and LipSync groups (the GameObjects holding a CubismEyeBlinkParameter or a CubismMouthParameter) | + +## Spots + +A spot of the home is a background prefab (a room) and a situation prefab placed on it. Two product stages +(`nnnotes.pipeline.spot`) assemble them; the roots of each are the objects of its subject's catalog key, and neither +keeps objects: its files are all it holds. + +`room.gltf` is a background as binary glTF. Its subject is a background key, `Spot//Background/`, and +its manifest `rooms/.json`. Its files: + +| File | | +|---|---| +| `room.glb` | every drawn mesh of the prefab (a MeshFilter with a mesh beside an enabled MeshRenderer), in hierarchy order | +| `room.json` | the summary: `backgroundKey`, `glb`, `meshCount`, `inactiveMeshes`, `materials`, `textures`, `samplers`, and what draws nothing (below) | + +Each drawn mesh is one glTF node without a transform: its vertices are baked into the prefab's space through the +world matrix of its node (the root's own transform included) and written in glTF's right-handed space (z negated, +v = 1 - v). Each non-empty submesh is one primitive with the renderer's material of the same index. A node inactive in +the prefab is kept with `extras.unityActive = false`. The situation's `backgroundPosition`, `backgroundRotation` and +`backgroundScale` are not baked in: they go on top, as the game applies them to the prefab root. + +Materials are translated from the shader's own render state, the first pass's: blend factors (colour and alpha), blend +operations, colour mask, culling, depth write, depth test and alpha to mask. A state the shader takes from a property +(`Blend [_SrcBlend] [_DstBlend]`, `Cull [_Cull]`, `ZWrite [_ZWrite]`, ...) is the material's value of that property, +else the shader's default for it. Each glTF material keeps the resolved state in `extras.unityRenderState` (and +`room.json` in `materials[].renderState`); a shader or a state glTF cannot express is an error of the task. Each +material and each texture is one object of the table, so materials and textures are told apart by object id. Each +texture a material's main slot names is one glTF texture with its own sampler (from the texture's filter, wrap and +mip count), and textures whose PNG bytes are equal share one glTF image. The `textures` of `room.json` has one entry +per glTF texture, in glTF order: the Unity texture's name. + +`room.json` also records `nodes`, each node's prefab `path` and Mesh object id in glTF node order, and +`materialIds`, Material object ids in glTF material order. These identify the serialized objects when the room +is displayed with a host scene's activation and material state. + +What draws nothing is not written and `room.json` lists it: a submesh whose material slot is empty +(`nullMaterialSubmeshes`: `mesh`, object `path` in the prefab, `submesh` index), a MeshFilter without a mesh +(`nullMeshFilters`: the object path) and a mesh without vertices or triangles (`emptyMeshes`: `mesh` and `path`). + +`spot.files` is a situation: its subject is a situation key, `Spot///`, and its manifest +`spots/.json`. Its input `master` is the spot's record of the master tables `MasterHomeSpot`, +`MasterText` and `MasterStoryHomeSpotTapTalkEpisode` (the row whose `_situationAssetPath` is the key, its name, its +tap talks), made at planning from the master data. Its files are `spot.json` and the Spine files under `spine/`: + +| Field of spot.json | | +|---|---| +| `spotId`, `master` | the spot's id and its `MasterHomeSpot` row | +| `name` | its name per language (`japanese`, `english`, `traditionalChinese`, `simplifiedChinese`, `korean`) | +| `tapTalk` | `{character id: [ADV ids]}` of its tap talks | +| `situationSettings` | the fields of the prefab's SpotSituationSettings (camera and interaction parameters) | +| `controller` | the fields of its SpotCharacterController but the character lists | +| `characters` | each SpotCharacter (a tap target) by character id: its object `path`, its fields, its `world` matrix and the world matrix of its `_focus` (`focusWorld`) | +| `spineCharacters` | each SpotSpineCharacter in hierarchy order: `path`, the name of the skeleton its SkeletonAnimation draws (`skeletonData`), that animation's settings (`animation`) and its `world` matrix | +| `skeletons` | each SkeletonDataAsset of the table, by name: `name`, `skeleton` and `atlases` (its files), `scale`, `defaultMix`, `mixes` | +| `matrixConvention` | `Unity world space (left-handed), 4x4 column-major` | + +World matrices are in the space of the situation prefab, its root's own transform included. Fields that name objects +are written as the prefab document writes them. A reference the prefab leaves empty is written as null: a Spine +character with no `_animation` has null `skeletonData`, `animation` and `world`, and a tap target with no `_focus` a +null `focusWorld`. + +| Spine file | From | +|---|---| +| `spine/.json` or `.skel` | the bytes of the SkeletonDataAsset's `skeletonJSON` (JSON when they start with `{`) | +| `spine/.atlas` | the bytes of each SpineAtlasAsset's `atlasFile`, as stored (an atlas two skeletons share is written once) | +| `spine/` | for each page of the atlas, the PNG of the `_MainTex` of the atlas asset's material of the same index | + +## Live sounds + +What a live plays besides its chart is two products of `nnnotes.pipeline.live`, each shared by every live that plays +it: the BGM of a music (`live.music`) and the sounds of an option set (`live.sounds`). Their roots are the objects of +the keys of their cue sheets, they read the sheets' `cri.audio` files, and neither keeps objects. A sound entry, of +either, is + +| Field | | +|---|---| +| `row` | the `MasterSound` row | +| `sheet`, `cue` | its cue sheet and cue name | +| `category`, `categories` | the row's category and the CRI categories of the cue (ACB command 65) | +| `volume`, `busSends` | the cue's volume (with the sheet's) and bus sends (ACB commands 146 and 111) | +| `lengthMs`, `other` | the cue's length, its other commands as stored | +| `layers` | one per track of the cue: `file` (`audio//`), the stream's `stream`, `awbId`, `sampleRate`, `channels`, `samples`, `loopStart`, `loopEnd`, the waveform's `loopFlag`, the track's and synth's `volume` and `busSends`, `other` | + +`live.music`: its subject is a music id (`MasterLiveMusic._id`), its manifest `music/.json` and its input +`master` the music's rows of `MasterLiveMusic`, `MasterSound` and `MasterSoundCueSheet`. Its files are `music.json` +(`format` 1, `musicId`, `music`: the BGM's `soundId`, `volume` and `startTimeSec`, and `sounds`: the BGM's entry) +and each layer's file in the web format of the parameter `audio`: `aac` (AAC-LC 160 kbit/s in MP4, the default), +`opus`, `vorbis`, `mp3`, or `flac`, the decoded FLAC itself. An AAC layer records `encoderDelay`, the priming samples +of the MP4 edit list, which a decoder that does not apply the edit list outputs first. The encode is bit-exact; the +atom `audio.encode` names the ffmpeg build. + +`live.sounds`: its subject names the note sound sets offered besides the default, `default` or +`NoteSePatternId=,,...` (the default set among them), and its manifest is `sounds/.json`. Its roots +include the game's sound settings, the `SoundVolumeSettings` and `AppConfigDefaultData` MonoBehaviours of the APK's +built-in files, and it reads the ACF of the APK (`assets/Cri/Sound/Sirius.acf`). Its input `master` holds the +option defaults of preset 1 (`MasterOptionDefault`), `MasterLiveNoteSe`, `MasterLiveSe` and the `MasterSound` and +`MasterSoundCueSheet` rows of its sounds. Its files are each sound's decoded FLAC and `sounds.json`: + +| Field of sounds.json | | +|---|---| +| `format` | 1 | +| `sounds` | the entry of each sound by `MasterSound` id: the note sounds of the default options and of every set offered, and the live sounds of the start and finish cheers and of the finish direction of every result | +| `categories` | the CRI category volumes of a fresh profile: each category's default volume, and the live categories' config volumes | +| `acf`, `react` | the ACF's `name`, `buses` and `categories`; its REACT (ducking) entries | +| `noteSe` | the note sounds of option preset 1: `types` (note sound type -> sound id), `volumes`, `mutes`, `patternId`, `useIndividualNoteSe`, `gekisouTraceVolume`; with sets offered `groups` (set id -> type -> sound id) | +| `liveSe`, `liveSeNames` | live sound type -> sound id, and the types' names | +| `timeline` | the intro and end timings of the cheers and the start voice | + +The `product` layout makes the `live.music` products of the musics whose BGM key the selection holds, and the +default `live.sounds` when the selection holds every cue sheet of its sounds; both need the master data. + +## Note highway + +What a live spawns and animates per note is three products of `nnnotes.pipeline.live`, each shared by every live that +loads it. Their roots are the objects of their keys, and each document names objects of its object table: + +| Product | Subject | Manifest | Roots | Document | +|---|---|---|---|---| +| `live.notes` | `default` | `notes/default.json` | the note view prefabs (`EmbLive/Prefabs/LiveGame/_note_view`), the slide and guide line views, the pair line, the groove loop, and the lane effect settings `Effect/Live/LaneEffect/effect001/LiveLaneEffectAssetSettings` | `notes.json` | +| `live.noteskin` | a note skin, `MasterLiveNoteSkin._assetName` | `noteskins/.json` | the skin asset `Live/Note//LiveNoteSkinAsset` and the three flick views | `noteskin.json` | +| `live.noteeffect` | a note effect set, `MasterLiveNoteEffectSkin._assetName` or its `Light` variant (the set the game loads at quality Low where the catalog has it) | `noteeffects/.json` | the effect settings `Effect/Live/NoteEffect//LiveNoteEffectAssetSettings` | `noteeffect.json` | + +Each document has `format` 1 and + +| Field | | +|---|---| +| `prefabs` (notes) | `{name: object id}` of the prefab GameObjects: `tap_note_view`, ..., `slide_line_view`, `guide_line_view`, `pair_note_line`, `note_groove_loop` | +| `laneEffect` (notes) | the object id of the lane effect settings | +| `settings` (notes) | the option defaults of preset 1 (`optionDefaults`) and the ranges (`optionRanges`) of the items the note runtime and the player read, the `MasterLiveSettings` values of the note speed and slides (`liveSettings`), the note display time of the default speed (`noteDisplayTimeMs`), the lane geometry, the default skin and effect set (`skin`, `effect`) and the lane effect set (`laneEffect`), and the names of every skin and effect set by option value (`skins`, `effects`) | +| `name`, `skin` (noteskin) | the skin's name and the object id of its `LiveNoteSkinAsset` | +| `name`, `settings` (noteeffect) | the set's name and the object id of its `LiveNoteEffectAssetSettings`, which references the effect prefabs | +| `bindings` | `{prefab GameObject id: {Animator node path: {clip id: [{path, attribute}]}}}`: the clips each Animator of the table's prefabs plays (its controller's clips and the `_loopAnim` of a `LoopAnimationTimeSync` on its `_animator`; for a skin, the arrow loop of each flick unit on its flick view's `LoopAnimationTimeSync`), named from the Animator's node (`nnnotes.unity.bindings`) | +| `shaders` | `{shader name: object id}`: the shader a lookup by name finds (a material names its shader by id). Copies of one shader in several bundles are one shader, named by the first id; a name of several different shaders names none of them | + +The object table holds every object the product reaches but the MonoScripts; of a shader it keeps the parsed form +(`json`), the variant index (`index`) and every GLSL ES 3.00 program; of a Mesh its `json` and `arrays` (the vertex +channels and submeshes as JSON, see [stages](stages.md#converters-unityexport)). The `product` layout makes `live.notes` when the +selection holds all its keys and the master data is there, and the `live.noteskin` and `live.noteeffect` products of +the skin and effect set keys the selection holds. + +## Live scene + +What exists on the live screen before the first note, and the start timeline of each band, is one product of +`nnnotes.pipeline.live`, shared by every live: + +| Product | Subject | Manifest | Roots | Document | +|---|---|---|---|---| +| `live.scene` | `default` | `scenes/default.json` | the scene `EmbScene/Live`, the lane line prefab, the judge effect view prefab, the combo and judgement sprite assets, the sprites of every lane skin (`MasterLiveLaneSkin`), the start timeline and LightWeight background of every band (`MasterBand`), and the built-in `GraphicsSettings`, `QualitySettings` and `PlayerSettings` | `scene.json` | + +The objects of a scene key are the objects of the scene's file outside a hierarchy: its root GameObjects, each a +prefab document, and its settings. A root references a component of another root with a `$ref`; the object table +lists that component as contained in the root GameObject holding it (`in`). + +`scene.json` has `format` 1 and + +| Field | | +|---|---| +| `scene` | the object ids of the scene's root GameObjects | +| `renderSettings` | the object id of the scene's `RenderSettings` | +| `graphics` | the object ids of the built-in settings: `graphics` (`GraphicsSettings`: the render pipeline asset, its renderers and their post-process data), `quality` (`QualitySettings`), `player` (`PlayerSettings`: the colour space) | +| `laneSkins` | `{lane skin asset name: {lane_base, lane_tap_area, out_side_line: Sprite id}}` | +| `prefabs` | the GameObject ids of `laneLine` (the inner lane lines), `judgeEffectView` and `barLineView` (the prefab the scene's bar line container instantiates) | +| `spriteAssets` | the object ids of the `combo` and `judgement` sprite assets | +| `bands` | `{band id: {timeline, background, tracks}}` of the bands whose start timeline the catalog holds: the timeline prefab's GameObject, the background Sprite, and `{AnimationTrack id: {name, prefab, animator}}`, the GameObject and node path of the Animator each track plays on | +| `settings` | the option defaults of preset 1 (`optionDefaults`) and the ranges (`optionRanges`) of the items the render path reads, the `MasterLiveQualitySettings` rows (`qualitySettings`), the default lane skin (`laneSkin`), every lane skin by option value (`laneSkins`), and the judgement sprite names by judgement (`judgementSprites`) | +| `bindings` | as in the note highway, with the clips of each band's timeline tracks on the Animators they play on | +| `shaders` | as in the note highway; of different shaders of one name, the name names the one `GraphicsSettings` always includes. It holds the shaders the live screen finds by name: `Hidden/CompositeRectBlit`, `Hidden/SimpleGaussianBlur`, `Sirius/Live/LiveSofdecPrimeYuv`, `Sirius/Live/LiveSofdecPrimeYuv_Android` and `UI/Default` | + +A live plays the timeline and background of one band. The object table and the shader files are kept as in the note +highway. The `product` layout makes `live.scene` when the selection holds the scene and the master data is there. + +## Lives + +A live is one chart of a music. It is a product of `nnnotes.pipeline.live` that holds what belongs to the chart and +names the products it plays with: + +| Product | Subject | Manifest | Roots | Document | +|---|---|---|---|---| +| `live` | `/[/]`: a `MasterLiveMusic` id, `easy`, `normal`, `hard` or `expert`, and optionally a band | `lives/_[_].json` | the chart `Live/MusicScore/<_musicScoreTextFileName>` and the jacket `Image/Jacket/<_jacketAssetName>` | `live.json` | + +The band is the one whose start timeline and LightWeight background the live plays. The game takes it from the +player's deck centre; a subject without a band takes the band of the music's first vocal character +(`MasterLiveMusic._vocalCharacterIDs[0]`, its `MasterCharacter._bandID`). The input `master` holds the music's rows +(`MasterLiveMusic`, its `MasterLiveMusicScore` row of each difficulty, the title and band names in every language from +`MasterText`, the BGM and jingle `MasterSound` rows with their cue sheets), the difficulty, the band and the manifest +paths of the parts. + +| File | | +|---|---| +| `live.json` | the document below | +| `chart.json` | the chart as shipped: the `TextAsset` of the chart key, un-gzipped | +| `notes.json` | the runtime notes the game converts the chart to, with `source`: the chart key, the music, the difficulty, the `MasterLiveMusicScore` id and the full combo count | +| `notes.mirror.json` | the same with the `MirrorChart` option on: lanes and flick directions mirrored | + +`live.json` has `format` 1 and + +| Field | | +|---|---| +| `musicId`, `difficulty` | the chart | +| `band` | `{band, source, note}`: the band, what it was taken from, and that the game takes it from the deck centre | +| `chart`, `notes`, `notesMirror` | the files above | +| `judgementNoteCount`, `noteCount`, `lastNoteTimeMs` | of the converted chart | +| `fullComboCount` | `MasterLiveMusicScore._fullComboCount` | +| `master` | the music's rows of the input `master` | +| `jacket` | the object id of the jacket Sprite | +| `parts` | `{part: manifest path}` of the products the live plays with: `scene` (`scenes/default.json`), `notes` (`notes/default.json`), `noteskin` and `noteeffect` (the note skin and effect set of the option defaults, `NoteDesignId` and `NoteEffectId` of preset 1), `sounds` (`sounds/default.json`) and `music` (`music/.json`). A path is relative to the layout's root, as a manifest path | + +The object table holds the jacket Sprite and its texture; the chart's `TextAsset` is in `chart.json`. The `product` +layout makes the live of each chart whose key and jacket the selection holds (with the master data), without a band. +`export live` asks for one live and its parts ([export live](commands.md#export-live)). + +### Live start UI + +`live.ui` has subject `//` and manifest +`liveui/__.json`. It reads the live scene, the jacket, the client's font assets, +LocalizeManager and TMP Settings, plus the chart's master rows. `ui.json` uses format `nnnotes.live-ui/1`. + +- `scene` identifies the manifest, prefab and canvas path in `live.scene`. +- `strings` contains the title, singer, credits, level and a fresh profile's high score (`0`). The title uses the + Japanese column; singer and credit labels use the requested language. Empty localized strings stay empty. +- `texts` maps TMP component ids to their path, displayed `text`, `setByInitialize`, localized layout `style`, + original `localized` font selection and generated `font` selection. +- `images` maps Image component ids to the selected jacket and difficulty Sprite ids. `difficultyIcons` lists + the canvas's icons, and `jacket` identifies the jacket. Canvas components and difficulty sprites belong to + `live.scene`; the jacket and its texture belong to this product. +- `fonts`, `materials`, `textures`, `materialKeywords`, `coverage`, `lineBreaking` and `shaders` describe the + generated text resources, with the same fields as the story font product. + +The `fonts` parameter is `open` (default) or `game`. Both modes generate glyph pages and use the client's font +metrics, layout settings and fallback chains. Open fonts use explicit `font:` byte inputs; their +contents participate in the task key. The exported object table holds the jacket and text shaders. The canvas +hierarchy is shared through `live.scene`. + +`live.json` includes `parts.ui`, a language-to-manifest map. `export live` requests the languages selected by +`--live-languages`, defaulting to `[catalog] language`. + +## Stories + +A story episode plays with products that several episodes share, made by `nnnotes.pipeline.story`: + +| Product | Subject | Manifest | Roots | Files | +|---|---|---|---|---| +| `story.sound` | a cue sheet name (`_cueSheetName` of an episode's cue sheet table) | `cuesheets/.json` | the cue sheet's key `Cri/Sound/` | `cues.json` and the cues' sounds | +| `story.crilips` | `default` | `crilips/default.json` | none (it reads the APK) | `crilips.json` and `crilips.bin` | +| `story.scene` | `default` | `storyscenes/default.json` | the ADV scene `EmbScene/Adv`, the prefabs `EmbCommon/Prefab/CameraManager`, `EmbCommon/Adv/Prefab/AdvCharacterField`, `AdvBackgroundField` and `AdvGlobalVolume`, the settings `EmbCommon/Adv/Settings/AdvPlayerSettings` and `AdvMasterIdSettings`, and the built-in `GraphicsSettings`, `QualitySettings` and `PlayerSettings` | `scene.json` and the object table | +| `story.asset` | a catalog key a story loads: a stage (`Adv/Stage/`), frame, effect, post effect, still, timeline, chat window, chat icon or stamp, transition | `storyassets/.json`, `` the key with `/` as `~` | the key | `asset.json` and the object table | +| `story.video` | a video key `Cri/Video/` (the video of a Movie / Clip command) | `videos/.json`, `` the asset with `/` as `~` | the key | `video.json` and `movie.webm` | +| `story.chat` | `default` | `storychat/default.json` | the shared chat shards `Adv/Chat/AdvChat-Text`, `Adv/Chat/AdvChat-Sound` and `Adv/Chat/AdvChat-SoundCueSheet` | `chat.json` | +| `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `storyui/.json` | the story UI's catalog keys (the widget, talk windows, chat widget, dialogs, UIManager, letterbox, animations and settings), the built-in `GraphicsSettings` and `LocalizeManager`, and the resources `DOTweenSettings` and `TMP Settings` | `ui.json` and the object table | +| `story.fonts` | `/` | `storyfonts/_.json` | the UI and font keys, the episode's chat windows and frames, and the UI settings | `fonts.json`, glyph pages and text shaders | +| `story` | a `MasterAdv` id | `stories/.json` | the episode key `Adv/Episode//` (``: the row's `_advEpisodeAsset`), its shard keys (the key with `-Text`, `-Sound`, `-SoundCueSheet`, `-Video`) and `EmbCommon/Adv/Settings/AdvPlayerSettings` | `episode.json` and `story.json` | + +`story.sound`, `story.crilips`, `story.video`, `story.chat` and `story` keep no objects: their files are all they +hold. +`story.sound` reads its cue sheet's `cri.audio` files; `cues.json` maps each cue name to the +first stream of that name (in stream order): `file` (the sound, next to `cues.json`), `sampleRate`, `channels`, +`samples` (frames at `sampleRate`, without encoder padding) and, for a looping stream, `loopStart` and `loopEnd` +(sample frames). A cue whose ACB sequence names CRI categories also has `categories`, the ordered category names +(for example `["Voice", "VoiceConfig"]`), read from sequence command 65 and the ACB's ACF category references. +The sounds are in the web format of the parameter `audio`, as for `live.music`: `aac` (the default) +records `encoderDelay`, the priming samples of the MP4 edit list; `flac` keeps the decoded FLAC. The `product` +layout makes the `story.sound` product of every cue sheet key the selection holds. + +`story.crilips` reads the CRI Lips library of the APK (`apk:lib/arm64-v8a/libcri_lips_unity.so`, in the arm64-v8a +split of an installed app) and writes the analysis data a player needs to run the CRI Lips analysis on a voice: the +descriptor `crilips.json` and the float32 weights `crilips.bin`. A library of a version nnnotes does not know makes +the product `unsupported` (`unsupported.crilips.version`). Only a request makes it. + +`story.scene` holds what the ADV screen loads before an episode's first line. `scene.json` has `format` 1 and + +| Field | | +|---|---| +| `scene` | the root GameObjects of the ADV scene that have their prefab document | +| `renderSettings` | the scene's RenderSettings | +| `prefabs` | the root GameObjects of `cameraManager`, `characterField`, `backgroundField` and `globalVolume` | +| `settings` | the `playerSettings` (AdvPlayerSettings) and `masterIdSettings` (AdvMasterIdSettings) MonoBehaviours | +| `graphics` | the built-in `graphics`, `quality` and `player` settings | +| `bindings` | `{prefab GameObject id: {Animator node path: {clip id: [{path, attribute}]}}}`: the clips each Animator plays through its controller, named in its prefab | +| `shaders` | `{shader name: object id}`: what a lookup by name finds; of different shaders of one name, the one GraphicsSettings always includes | + +The renderers' shaders and post-processing textures are reached through the graphics settings' references (the +render pipeline asset, its renderer data and their features). `story.asset` holds the objects of one catalog key +and what they reach; an asset several stories load is one product. `asset.json` has `format` 1, `key`, `objects` +(the key's object ids), `bindings` and `shaders` as above. The CRI Lips data, the story assets and the videos are +made only when a request names them; the `product` layout makes `story.scene` when the selection holds every key of +it. + +`story.video` is one movie of a story. Its key loads a CriWare.Assets MonoBehaviour describing the movie and the movie +itself, a USM; `movie.webm` is the `movie.webm` that `cri.movie` makes of the USM with its parameter `format` `webm` +([stages](stages.md#cri-stages)): the first video stream, VP9 as stored or re-encoded as VP9 when it is in another +codec or has an alpha stream (the alpha becoming the WebM's alpha channel), and the first audio stream as Opus. +`video.json` has `format` 1, `file` (`movie.webm`), `codec` (the stored video codec: `vp9`, `h264` or +`sofdec.prime`), `width` and `height` (the coded size), `displayWidth` and `displayHeight`, `frameRate` +(`[numerator, denominator]`), `frames` and `tracks`, the tracks of `movie.webm` (`kind`, `channel`, `codec` and, for a +converted stream, its `source` codec). + +`story.chat` holds the rows of the chat shards every chat of a story reads. `chat.json` has `format` 1, `text` (the +chat message texts), `sounds` (their sound rows) and `cueSheets` (the cue sheet rows those name), each the `_allData` +rows of its shard as stored; a shard key without a TextAsset gives `[]`. The `product` layout makes it when the +selection holds the three shard keys. + +`story.ui` is the ADV story UI in one client language: the ADV front canvas, the talk windows, the chat phone, the +dialogs and the settings, with the text records of their texts in that language. Its input `master` holds the +`MasterText` rows, each text in the language's column (`{"texts": {MasterText id: text}}`, the empty text when the row +has none). `ui.json` has `format` 1 and `language` (`code`, `mode` (the LanguageMode), `field` (the text column), +`lineSpacing`, `fontNames`, `additionalFontNames`, `materialTypes` and `fontSwap`, the Japanese font asset names mapped +to the language's), `widget`, `talkWindows` (each window's name to its object), `chatWidget`, `dialogs`, `uiManager`, +`letterbox`, `clips`, `controllers`, `settings` (`masterIdSettings`, `dotween`, `tmpSettings`, `localizeManager`), +`graphics`, `texts` (each TMP text by its component id: `localized`, the font asset, material and line spacing its +LocalizeText gives it in the language, or null, and `style`, its text record), `textStyle` (the language's field, the +units and the line metrics of the font roles the texts use), `masterIdTexts` (each `AdvMasterIdSettings` text with its +MasterText id and text), `chatStatusTexts` (incoming-call and lock-screen status keys to their texts), `bindings` +and `shaders`. Text styles use settings read from the client's font assets. The input `ui.objects` is the closure +of the UI roots; font assets and chat windows are additional assembly inputs for metrics and status text keys. +The retained object table is the UI closure, so the font assets' files remain in the depot. The input +`font-bundles` names the bundles holding the font assets, and `apk:AndroidManifest.xml` supplies the client version. +A client requests the product for each language it uses. + +### Overlay hosts and simple UI + +For episodes with `MasterAdv._playbackMode` 1, `story.json` includes a `host` descriptor. The host is selected +from one matching `MasterStoryHomeSpotTapTalkEpisode`, `MasterHomeSpot` or `MasterStoryLiveResultEpisode` row. +It identifies the host kind and subject, the home talk kind and character where applicable, and the area title +in each language. A missing or ambiguous host is an error. + +`parts.host` maps its subject to `storyhosts/home_.json` or `storyhosts/afterlive.json`. A home host also +uses `parts.spots` and `parts.rooms`, whose keys are the situation and background addresses. These name the shared +`spot.files` and `room.gltf` products. + +`story.host` writes `host.json` with format `nnnotes.story-host/1`. `prefabs` identifies the host's UI, capture +camera, background and situation objects. `overlayRoot` and `openedSequences` are `{prefab, path}` locations; +`layoutRoot` also carries the serialized `SimpleAdvLayoutRoot` fields. `home` identifies the room and spot +products, the Spot scene roots, and `backgroundState`: activation after selecting the situation, and the floor +renderer's queue. `afterlive` identifies the result background image and sprite, reward panel and substitute +elements. Graphics, DOTween settings, animation bindings and shaders are object references. + +`parts.simpleui` maps each language to `storysimpleui/.json`. `story.simpleui` writes `ui.json` with +format `nnnotes.story-simple-ui/1`: the simple talk-window, system-message and UIManager prefab ids, settings, +text records, animation bindings and shaders. The episode's `story.fonts` product includes these texts and the +area title in either font mode. Font files are read as inputs; the retained UI closure uses fenced font references. + +### Episode fonts + +`story.fonts` contains the font records for one episode and language. Its `episode` input is the first run's +`episode.json`, supplied in the planning fact `product_inputs` under `episode:`. This snapshot determines the +chat-window and frame roots before object export. Its other inputs are localized `MasterText` rows, the font +bundle record, APK version and object table. With parameter `fonts: open`, configured font files enter as +`font:` and optionally `font:emoji`; `fonts: game` reads the client's TMP assets. + +`fonts.json` has format `nnnotes.story-fonts/1`, `language`, `source`, `fonts`, `textures`, `materials`, +`materialKeywords`, `coverage`, `lineBreaking` and `shaders` (shader names to retained object ids). +`texts` maps TMP component ids to `style`, `localized` (the LocalizeText result, or null), and `font` (the effective +font asset, material and line spacing to render, including generated open font names). Ruby and Korean-adjustment +settings accompany the relevant text records. `sprites` maps driven TMP component ids to their sprite asset and +tint flag; `spriteAssets` and `emojiSpriteAsset` are present when text can draw emoji sprites. + +Glyph selection includes episode lines and names, the title, localized UI labels, status text, chat runtime +strings and frame text. It follows each primary font's fallback chain and models synthesized control characters, +underline and missing glyphs. The product retains glyph pages and the required shader programs; font files and +source atlas objects remain in the depot. Page file names in `textures` resolve through the product's `files`. + +`story` is one episode. Its input `master` is the episode's master record: its `MasterAdv` row, the `MasterText` row +of its title (none when `_titleTextId` is empty) and every `MasterAdvChat` row. `episode.json` is the episode as +`export episode` writes it, each resource given by its kind and address: the command list with the lines of every +language, the voices, sounds, cue sheets and videos the commands name, and the resources the episode loads. +`story.json` has + +| Field | | +|---|---| +| `format` | 1 | +| `advId` | the `MasterAdv` id | +| `episode` | `"episode.json"`, the file next to it | +| `chats` | `{chat id: MasterAdvChat row}`: each chat id the commands name (`TargetChatID`) | +| `effects` | `{TargetName: "Adv/Effect/"}`: the effect instances, the first asset an Effect command loads under a name | +| `parts` | the manifests of the products the story plays with: `scene` (`storyscenes/default.json`), `assets` (`{address: manifest}` of each `story.asset`), `models` (`{address: manifest}` of each `live2d.model`), `sounds` (`{cue sheet: manifest}` of each `story.sound`), `videos` (`{address: manifest}` of each `story.video`), `chat` (`storychat/default.json` when a command names a chat, else null) and `crilips` (`crilips/default.json` when a voice command has a target and voice cues and sets neither `IgnoreData` nor `IgnoreLipSync`, else null) | + +A model's MotionSync (model.json `motionSync`) decides whether a lip-synced voice runs the CRI Lips analysis, so +`crilips` names the data whenever a voice can reach it. The episode's talk windows are listed in `episode.json` +`resources`. `parts` depend on the episode's commands, which only the story's result holds: a command +asks for the `story` product first, reads its `story.json`, then asks for the story with every product `parts` names +(`nnnotes.pipeline.story.part_products`, with the requested languages); `export story` works this way. +`parts.ui` and `parts.fonts` map every client language to its product manifest. The command exports those entries +for the requested languages. The `product` layout makes the story of +every episode whose key the selection holds. + +## UI library products + +`ui.pack` assembles one catalog key and its reachable objects. Its `ui.json` (`nnnotes.ui-pack/1`) names root prefabs, controllers, animation bindings and shaders. The product layout writes `index.json` (`nnnotes.ui-library/1`) over the selected UI products; all referenced data lives in their object tables. See [UI libraries](ui.md). + +A stage with `INCLUDE_FONTS = True` requests traversal through its referenced font objects. The request records these roots in `font_roots`; unit selection and assembly follow the same rule. Other products keep their own font selection behavior. + +The maintainer tool `scripts/gen_font_settings.py TASK.json --depot DEPOT -o OUTPUT.json` generates a font settings table from a saved text-product task. It reads the task's object table, font-bundle identities and APK manifest through the depot. The task must contain the complete font inputs, as used by `story.fonts` or `live.ui`. + +## Site snapshots + +`nnnotes.site.products.Snapshot` takes a depot and product-layout manifests keyed by path. It builds each product's +dependencies first, using `site.references` to rewrite the declared `parts` fields, `site.asset` for the files and +`site.manifest` for their manifest. Every transformation is recorded as a depot task in `snapshot.run`. + +The immutable manifest path is `products/.json`, where the content id is that of the manifest itself. +References in a live, story or home host point at those immutable paths. A dependency change gives its parents +new manifest contents while unrelated products keep their identities. Object ids and file roles stay unchanged. +Only declared reference fields are rewritten; text and serialized component values retain their values. + +Stories retain the selected language groups in their part index. With no explicit language selection, the +snapshot uses the languages whose UI and font products are available. An explicitly requested missing language +or a missing required dependency is an error. Public aliases may use region directories; their `root` points +back to the site root. + +Product file entries accept whole files, compressed files and split JSON objects, including object JSON roles. +A split entry has `{parts, size}`, with `[key, asset, size]` rows, or `[key, asset, size, stored]` for compressed +parts. Rejoining the key names and value texts reconstructs the exact minified document and its byte count. + +`nnnotes.site.publish.materialize` places a complete snapshot and its listing files from the depot. It writes +assets, immutable manifests, public aliases, then indexes. A failed replacement stops publication before file +collection and keeps the ownership journal for retry. After every replacement succeeds, files owned by the +preceding layout that are outside the supplied complete reference closure are removed. Unowned files are kept. diff --git a/docs/quickstart.md b/docs/quickstart.md index 93d1c43..2c80669 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -1,78 +1,94 @@ # Quick start -This walk-through takes you from an empty configuration to an exported ADV episode and a story directory. It needs -your own installation of the game: its `base.apk`, and the keys and server addresses of your client -([where the values come from](configuration.md#where-the-values-come-from)). +This guide exports one episode, a playable story product and a static story site. Use your own matching client +files, keys and server addresses; [configuration](configuration.md#where-the-values-come-from) explains the inputs. -## 1. Install +## 1. Install the tools ```sh -pip install 'nnnotes[fonts]' +pip install 'nnnotes[fonts]==0.3.0' ``` -Python 3.11 or later. Install [vgmstream](https://vgmstream.org/) (`vgmstream-cli`) and -[FFmpeg](https://ffmpeg.org/) as well, on `PATH` or named in the config file; stories decode their audio and -videos with them. +Use Python 3.11+. Install [vgmstream](https://vgmstream.org/) (`vgmstream-cli`) and +[FFmpeg](https://ffmpeg.org/) on PATH, or configure their executable paths. They decode story audio and video. +The final site step also needs Node.js 22+. -## 2. Write the config file +## 2. Configure your inputs ```sh nnnotes config init --user +nnnotes config check ``` -In a terminal this asks for each setting in turn, among them the bundle and master data keys, the names of your -regions with each region's CDN base and API root, the catalog language, and the paths of the cache, the APK and the -tools. Enter leaves a setting empty; the keys are read without echo. The file is written to the per-user location, -which every command reads from any directory (`nnnotes config path` shows it). +The wizard asks for region names, keys, server addresses and paths. Set `paths.depot` to a persistent directory, +`paths.apk` to your game APK and `catalog.language` to the desired language. Keys are entered without echo. +`config check` lists setting names, their source and validity. A command names any required setting it lacks. +The per-user file is available from any directory; `nnnotes config path` shows the configuration search order. -Check it: +## 3. Inspect the catalog and prepare master data ```sh -nnnotes config check +nnnotes catalog keys --prefix Adv/ --limit 5 +nnnotes master version +nnnotes master download --latest -o work/master-bin +nnnotes master decode work/master-bin -o work/master +nnnotes config set paths.master work/master ``` -Every setting is listed with where it comes from and whether it is valid, never with its value. A command that -needs an unset setting stops with exit status 2 and names the TOML key, the environment variable and the flag that -set it. +The catalog is retained in the depot. Master downloads are checked against their manifest; decoding writes a +JSON file for each table. Use a story ID available in your selected region in the examples below. -## 3. Look at the catalog +## 4. Export the episode and story ```sh -nnnotes catalog keys --prefix Adv/ --limit 5 +nnnotes export episode 10462 -o out/episode.json +nnnotes export story 10462 --fonts game -o out/story ``` -The first command downloads the region's catalog into the cache; the keys it lists are the addressable keys of the -game's assets. `nnnotes catalog browse` serves a local web page to browse the catalogs and bundles of every -configured region. +The episode file contains commands and localized lines. The story directory contains a product manifest at +`stories/10462.json`, its referenced products and content-addressed files under `assets/`. It includes the +scene, models, text resources and any media the story uses. Audio defaults to AAC; `--format` selects another +supported encoding. UI and font products use the catalog language by default. -## 4. Get the master data +For two languages, add `--story-languages ja,en`. Open fonts are also supported: +`--fonts open --font ja=fonts/NotoSansJP.otf --font en=fonts/NotoSans.ttf`. +Supply a font for every selected language, either as a flag or in `[paths.fonts]`. + +## 5. Build, validate and serve a site ```sh -nnnotes master version -nnnotes master download --latest -o work/master-bin -nnnotes master decode work/master-bin -o work/master -nnnotes config set paths.master work/master +npm install ournotes-player@0.2.0 +nnnotes site build --name stories -o out/site --story 10462 --fonts game --player node_modules/ournotes-player +node node_modules/ournotes-player/scripts/validate-data.mjs out/site +python -m http.server 8000 --directory out/site ``` -`master version` asks the region's game API which master data version it serves now; `master download` fetches -that version and checks every file's SHA-256; `master decode` writes one JSON file per table. The last line makes -the decoded directory the default of every command that reads master data. +Open `http://localhost:8000/story/`. Configure the page's Cubism Core and, for the features in your story, +MotionSync Core or Spine runtime; see the player's +[getting started guide](https://github.com/empty-sekai/ournotes-player/blob/main/docs/getting-started.md). +These runtimes have their own licenses and are provided by the page. + +The site builder accepts a built ournotes-player 0.2.x package, validates its package identity and required files, +and reuses the story's depot tasks. The validator checks manifests, references and data contracts before playback. + +## 6. Update and retain your work -## 5. Export an episode and a story +Reuse `--name stories` to update selected topics while retaining the rest. Use a different name for a separate +site. To materialize its saved products with the current player, configure `paths.player` and run: ```sh -nnnotes export episode 10462 -o out/episode_10462.json -nnnotes export story 10462 -o out/story_10462 +nnnotes site build --name stories -o out/restored-site --player-only --player node_modules/ournotes-player +nnnotes depot verify +nnnotes depot gc --dry-run ``` -`export episode` writes the episode's command list with its lines in five languages and the index of its voices, -sounds and videos. `export story` writes the whole story directory: the episode, its audio, the stage scene and -shaders, the story UI, frames, effects, stills, chats and videos; the Live2D models it uses go to `out/live2d/`, -where the next story reuses them. Add `--json` to any command to get its report as JSON. +The depot and the published output are separate directories. Read [retention](depot.md#retention) before collecting +stored results. Add `--json` to a command for its structured report. ## Next -- [Export stories and lives](export.md): the other single-subject exports and their options. -- [Build an ournotes-player site](site.md): charts, models and stories as one static site. -- [Export every asset](assets.md): every object of the selected bundles through the content-addressed store. -- [Command reference](cli.md): every command and option. +- [Export stories and lives](export.md) for individual products. +- [Build a site](site.md) for charts, regional selections, language choices and hosting. +- [Depot](depot.md) for shared storage, verification and collection. +- [Japanese release](jp.md) for its catalog and APK requirements. +- [Commands](cli.md) for flags and exit statuses. diff --git a/docs/schemas.md b/docs/schemas.md index 051f6fe..003c67e 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -2,7 +2,7 @@ The documents nnnotes writes in a fixed format have JSON Schemas (draft 2020-12), shipped in the package `nnnotes.schemas`. Each schema has the `$id` `urn:nnnotes:schema:`, and the shared definitions are in -`common`. Every document names its format and version in a `schema` field, such as `nnnotes.plan/1`; within a major +`common`. Documents name their format and version in `schema` (such as `nnnotes.plan/1`) or `format`; within a major version fields are only added ([versioning](contracts.md#versioning)). ```python @@ -14,26 +14,67 @@ schemas.load("plan") # the schema as a dict | Schema | Format | Document | |---|---|---| -| `addresses` | `nnnotes.addresses/1` | the address table of a catalog version and the censuses of its bundles | +| `addresses` | `nnnotes.addresses/3` | the address table of a catalog version and the censuses of its bundles | | `artifact` | `nnnotes.artifact/1` | the content, provenance and semantics of one output file | -| `artifacts` | `nnnotes.artifacts/1` | the artifact table: the artifacts of every object, with content id, size and extension | | `catalog-diff` | `nnnotes.catalog-diff/1` | the difference between two catalog versions (`catalog diff`) | | `catalog-index` | `nnnotes.catalog-index/1` | the index of a catalog pair: remote and APK catalogs, every location in catalog order | -| `catalogs` | `nnnotes.catalogs/1` | the catalog versions of a store (`catalog versions`) | -| `census` | `nnnotes.census/1` | the census of one bundle: serialized files, type trees and objects, read without decoding | +| `catalogs` | `nnnotes.catalogs/1` | the catalog versions of a depot (`catalog versions`) | +| `census` | `nnnotes.census/2` | the census of one bundle: serialized files, type trees, objects and what they reference, read without decoding | | `common` | | definitions shared by the other schemas | | `cost` | `nnnotes.cost/1` | the measured cost of a task's last run | | `coverage` | `nnnotes.coverage/1` | the coverage of a run per class and per reason code | +| `crilips` | product document | CRI Lips tensor blocks and frontend constants | +| `episode` | product document | story commands and resource tables | | `failures` | `nnnotes.failures/1` | the failed tasks and items of a run | | `layout` | `nnnotes.layout/1` | the files a layout owns in its output directory | +| `live` | `1` | live chart entry and shared parts | +| `live-music` | `1` | music layers and cue metadata | +| `live-noteeffect` | `1` | note effect resources and animation bindings | +| `live-notes` | `1` | note prefabs, settings and animations | +| `live-noteskin` | `1` | note skin resources and animation bindings | +| `live-scene` | `1` | scene objects, settings and band timelines | +| `live-sounds` | `1` | sound groups and category routing | +| `live-ui` | `nnnotes.live-ui/1` (`format`) | live start strings, text styles, glyph pages and image bindings | +| `model-json` | `3` | Live2D model objects and animation bindings | | `music-data` | `nnnotes.music-data/2` | every song and chart of one master data version ([music data](music-data.md)) | -| `plan` | `nnnotes.plan/1` | what a run would do and why (`store plan`) | +| `objects` | `nnnotes.objects/1` | the objects a product reaches: their class, item status and files ([products](products.md)) | +| `plan` | `nnnotes.plan/1` | what a run would do and why (`depot plan`) | +| `prefab` | product document | prefab nodes, transforms and components | +| `product` | `nnnotes.product/1` | a product's manifest: its files and the files of the objects it uses, as content-addressed assets ([products](products.md)) | | `result` | `nnnotes.result/1` | the result of a task, a function of its key alone | +| `room` | product document | room geometry, materials and textures | | `run` | `nnnotes.run/1` | a run manifest: context, selection, tasks with keys and statuses, layouts | | `scripts` | `nnnotes.scripts/1` | every MonoScript of the censuses, by object id | -| `task` | `nnnotes.task/1` | one stage applied to one subject, with everything needed to run it (`store run`) | +| `site` | `nnnotes.site/1` | a named site's complete region-topic catalog and immutable product references | +| `spot` | product document | spot settings, characters and Spine data | +| `story` | `1` | story entry and shared parts | +| `story-asset` | `1` | story media prefab objects | +| `story-fonts` | `nnnotes.story-fonts/1` (`format`) | an episode's text bindings, font records, glyph pages and text shaders | +| `story-host` | `nnnotes.story-host/1` (`format`) | an Overlay story's host screen, layout and initial state | +| `story-scene` | `1` | story scene objects and graphics settings | +| `story-simple-ui` | `nnnotes.story-simple-ui/1` (`format`) | localized Overlay talk-window and system-message prefabs | +| `story-sound` | product document | cue metadata and waveform files | +| `story-ui` | `1` | localized story UI and text bindings | +| `story-video` | `1` | video dimensions, timing and media tracks | +| `task` | `nnnotes.task/1` | one stage applied to one subject, with everything needed to run it (`depot run`) | +| `ui-library` | `nnnotes.ui-library/1` | selected UI keys and their product manifests | +| `ui-pack` | `nnnotes.ui-pack/1` | native UI roots, prefab and controller identities, animation bindings | | `view` | `nnnotes.view/1` | a semantic view of a master table, each row with its names and the objects it names | + The documents of the asset export are described in [contracts](contracts.md), the views in [views](views.md). The schema files are in [src/nnnotes/schemas](https://github.com/MetaSekaiLab/nnnotes/tree/main/src/nnnotes/schemas). + +## Product documents + +The product root and prefab schemas are listed in [Product document schemas](contracts.md#product-document-schemas). Their fields match the shared object interfaces consumed by ournotes-player. + +Compare the shared constraints with an ournotes-player checkout or extracted package: + +```sh +python scripts/check_product_schemas.py /path/to/ournotes-player +``` + +The check covers product, live, model, story, prefab, room, spot, audio, video and UI documents. Schema identifiers, +titles and descriptions may differ by package; validation constraints must agree. diff --git a/docs/site.md b/docs/site.md index 668bb57..029ebae 100644 --- a/docs/site.md +++ b/docs/site.md @@ -1,77 +1,63 @@ # Build an ournotes-player site -`nnnotes site build` writes a static site of charts, Live2D models and stories that -[ournotes-player](https://github.com/empty-sekai/ournotes-player) plays in the browser. The site is a directory of -plain files: the player's pages and bundles, one manifest per chart, model and story, and the files they read, -stored once by content hash. +`nnnotes site build` publishes a static site from depot products. The site contains player pages, chart/model/story listings, public entry manifests, immutable product manifests, and content-addressed assets. -## What it needs +## Requirements -- The settings of the export commands: keys, the region's CDN, `[paths] apk`, and the decoded master data of every - region the site serves ([configuration](configuration.md)). -- A built ournotes-player: its checkout after `npm run build`, or its installed package, given with `--player` or - `[paths] player`. -- Node.js 20+, which runs the player's read-set script to list the files each chart reads; only those are stored. -- vgmstream and FFmpeg for the audio. -- For stories: `pip install 'nnnotes[fonts]'` and a font file per story language (below). +Configure `[paths] depot`, the catalog and APK, and the decoded master data for the selected charts and stories. Supply a built [ournotes-player](https://github.com/empty-sekai/ournotes-player) 0.2.x checkout or npm package through `--player` or `[paths] player`. Node.js 22 or newer runs chart read sets. Audio conversion uses vgmstream and FFmpeg. Text rendering needs the `fonts` extra and either `--fonts game` or a font file for every requested language. -## Build a few charts +Install the complete player package with `npm install ournotes-player@0.2.0`, then pass +`--player node_modules/ournotes-player`. The browser-only ZIP supplies bundles for embedding; the site builder +also needs the package metadata, scripts and example pages. It checks the package name, stable 0.2.x version +and required files before starting. + +## Select products ```sh -nnnotes site build -o out/site --player path/to/ournotes-player \ - --pair 100001:expert --pair 100001:hard +nnnotes site build --name example -o out/site --player path/to/ournotes-player \ + --pair 100001:expert --font ja=fonts/NotoSansJP.otf +nnnotes site build --name example -o out/site --story 10004 --story-languages ja,en \ + --font ja=fonts/NotoSansJP.otf --font en=fonts/NotoSans.ttf ``` -`--pair MUSIC_ID:DIFFICULTY` (repeatable) adds charts; `--all` adds every chart of the master data. Serve the -directory with any static file server and open `index.html`: the chart list, with `?music=&difficulty=` -playing one chart. - -The build is incremental. A chart, model or story whose manifest exists is skipped unless `--force`, and files no -manifest references any more are removed, so the same command run again after a game update adds what is new. -`--tmp DIR` (default `SITE.tmp`) keeps a cache of decoded audio, encoded images, shader dumps and read sets keyed by -everything they were made from; deleting it is safe outside a build. +`--pair`, `--live2d`, and `--story` are repeatable. Each updates those topics and retains the site's other topics. `--all`, `--all-live2d`, and `--all-stories` replace the corresponding kind's complete selection in the requested regions. Story products include their model dependencies, which also appear in the model listing. -## Add models and stories +`--name` identifies the site in the depot and defaults to `default`. Use different names for independent sites sharing a depot. The latest successful run records the complete catalog and layout. A new output directory or machine can restore that state from the same depot, including its configured remote. ```sh -nnnotes site build -o out/site --player path/to/ournotes-player \ - --all --all-live2d --all-stories \ - --font ja=fonts/NotoSansJP.otf --font en=fonts/NotoSans.ttf \ - --font zh-Hant=fonts/NotoSansTC.otf --font zh-Hans=fonts/NotoSansSC.otf --font ko=fonts/NotoSansKR.otf +nnnotes site build --name example -o restored/site --player-only ``` -- `--live2d MODEL` (repeatable) or `--all-live2d` adds Live2D models; the site gets the player's model page. -- `--story ADV_ID` (repeatable) or `--all-stories` adds story episodes, and the models they use; the site gets the - player's story page. -- Story text is laid out with font assets generated from your font files, holding exactly the characters each - episode shows. Give one per language of `--story-languages` (default all five) with `--font LANG=PATH` or - `fonts.` in `[paths]`. `--font emoji=PATH` gives a colour emoji font for the emoji of chat and talk texts. - `--fonts game` uses the game's own font assets instead. +`--player-only` publishes the saved products and current player pages. Product extraction and site transformations use task keys: unchanged inputs reuse their committed results, while changed inputs produce new manifests. Depot collection follows the successful layout's complete dependency closure. -## Several regions +## Regions and languages -```sh -nnnotes site build -o out/site --player path/to/ournotes-player --all --all-regions -``` +`--site-region REGION` selects a configured region and is repeatable; `--all-regions` selects all configured regions. Each region uses its own master data. Equal product content and listing metadata share an alias; other regional versions get an alias under their region. Chart and story pages select them with `?region=&lang=`. JP stories/models and international stories/models require separate sites; differing content for one model ID also requires separate sites. + +The catalog language supplies default text. `--story-languages ja,en,...` selects story text/font products and defaults to the catalog language. Supply fonts with `--font LANG=PATH` or `[paths.fonts]`; `--font emoji=PATH` supplies a colour emoji font. `--fonts game` selects the game's font assets. + +## Player read sets and variants -`--site-region REGION` (repeatable) or `--all-regions` chooses the regions the site serves; each region's master -data is `[servers.] master`. Regions whose chart tables are equal share one manifest per chart; a region -whose tables differ gets its own. The list texts are written in all five languages, and the chart list page switches -with `?region=&lang=`. +A chart read set is a depot task whose inputs include the player sources, product files, requested settings and Node implementation identity. The first chart and a deterministic sample compare the player's plan with a full simulation. A failed plan uses a full simulation; a mismatch switches subsequent reads to full simulation. The site retains structured product documents, their image/audio/WebGL2 dependencies, and additional binary roles read by the selected settings. -## Audio, compression and hosting +`--live-option` adds note skins, effect sets, qualities, mirrored charts, bar lines and note sound sets. Read sets cover the default settings and offered combinations. Each chart manifest includes its immutable variant product paths and supported quality values. -- `--format aac|opus|vorbis|mp3|flac` (default `aac`) is the BGM and story audio format; `--no-audio` stores none. -- `--compress gzip|br|none` (default `gzip`) stores the compressible files encoded when that makes them smaller. A - gzip site works from any static host; a brotli site needs `Content-Encoding: br` on its `.br` files. -- `--workers N` sets the parallel music, model and story processes (`0` builds in this process) and - `--read-workers N` the read sets run at a time. +## Publication and hosting -## When something fails +`--format aac|opus|vorbis|mp3|flac` selects audio encoding; `--no-audio` omits sound streams. `--compress gzip|br|none` selects storage encoding. gzip assets work as plain static files; brotli assets need `Content-Encoding: br`. -Charts, models and stories that fail are listed in the summary and in `SITE.failures.json`, -`SITE.model-failures.json` and `SITE.story-failures.json`, and the exit status is 1; the others are built. Fix the -cause and run the same command again: what was built is skipped. +Publication writes assets, immutable products, aliases/pages, and then indexes. It collects retired owned files after successful replacements and retains unowned files. A failed publication keeps its journal and successful-run pointer for retry. Serve the directory with a static server and open `index.html`, `live2d/`, or `story/`. + +`--workers`, `--memory`, and `--fetch-workers` control product task execution. `--stored-catalog LABEL|SHA` selects a catalog already registered in the depot. Reports include product task counts, site task counts, read-set checks, and file placement counts. See the [command reference](cli.md#nnnotes-site-build) for all flags and [site snapshots](products.md#site-snapshots) for product references. + +## Validate and open + +```sh +node node_modules/ournotes-player/scripts/validate-data.mjs out/site +python -m http.server 8000 --directory out/site +``` -The full option list is in the [command reference](cli.md#nnnotes-site-build), the site layout and every rule in -[Command outputs](commands.md#site-build). +Open the chart page at `/`, the model page at `/live2d/` or the story page at `/story/`. +Supply Cubism Core and the MotionSync/Spine runtimes needed by the selected content through the player's page +configuration. See its [runtime guide](https://github.com/empty-sekai/ournotes-player/blob/main/docs/getting-started.md#browser-runtimes) +and [hosting guide](https://github.com/empty-sekai/ournotes-player/blob/main/docs/embedding.md#hosting-the-data). diff --git a/docs/stages.md b/docs/stages.md index 16d9e9f..591786c 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -2,33 +2,53 @@ The asset export is built from three layers: -- **Stages** apply one unit of work to one subject (a bundle, a content id, a model key, a view). Each has a name and +- **Stages** apply one unit of work to one subject (a bundle, an object, a content id, a model key, a view). Each has a name and a version; a task is a stage applied to one subject, and its key covers exactly what its output depends on ([contracts.md](contracts.md)). - **Converters** turn the objects of one class inside `unity.export` into artifacts. Each carries its own version; a - bundle's task includes only the converters of the classes the bundle holds. + task includes only the converters of the classes its objects have. - **Atoms** are the pure functions stages and converters are made of: no file or network IO, no threads, the same output for the same input. Each atom has exactly one reference implementation. ## Stages -Every stage is at version 1, except `unity.census` (version 2: it lists the scripts animation clip bindings name) -and `cri.movie` (version 2: every stream kind, codec and channel of a USM), and `catalog.index` (version 2: -gzip input and Japanese remote asset placeholders). +Every stage is at version 1, except `unity.census` (version 4: it lists what every object references, the +hierarchy holding it and the Resources.Load paths of a ResourceManager), `link.addresses` (version 4: the +Resources.Load paths, the built-in selectors and the objects of a scene key), `live2d.model` (version 2: assembled from the exported objects), `unity.export` (version 2: one task per unit), `cri.movie` (version 2: every stream kind, +codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japanese remote asset placeholders). | Stage | Subject | Inputs | Outputs | |---|---|---|---| | `catalog.index` | a catalog (remote + APK) | the catalog files | every location (with the bundle hash, crc and size), bundles and raw remote files | -| `unity.census` | a bundle | the bundle | serialized files, externals, objects (file, path id, class, size, type hash, name, script, the scripts an AnimationClip's bindings name), container, scene hashes, dependencies, script fields | +| `unity.census` | a bundle, or a built-in file of the APK | the bundle | serialized files, externals, objects (file, path id, class, size, type hash, name, script, the scripts an AnimationClip's bindings name, the objects it references, the hierarchy holding it), container, scene hashes, dependencies, script fields, the ResourceManager's paths (Resources.Load) | | `link.scripts` | global | the censuses holding MonoScripts | serialized file and path id -> assembly, namespace, class | -| `unity.export` | a bundle | the bundle, the script entries its MonoBehaviours use | the objects' artifacts and one item per object | +| `unity.export` | a unit of a bundle or of a built-in file of the APK (below), by the stable address of its root object | the bundle; context `unit` (the root's object id), `bundle`, the script entries its objects use and, for files without type trees, the type trees nnnotes ships for their type hashes | its objects' artifacts and one item per object | | `sprite.crop` | a SpriteAtlas | the atlas' JSON, the images of its textures, the metas of the sprites of other bundles it packs | the sprites' images | | `cri.audio` | ACB content | `acb` (a raw file of the catalog, or the `acb` artifact of a cue sheet held in a bundle), `awb` (an AWB the catalog pairs with it), `boot` (the game's boot data: the key's source) | FLAC per stream, `cues.json`, `streams.json` | | `cri.movie` | USM content | `usm` (a raw file of the catalog, or the `usm` artifact of a movie held in a bundle), `boot` | every stream as stored (video, alpha, audio, subtitles), `movie.mkv` (or `movie.webm`) | -| `live2d.model` | a Live2D model key (`Character/Live2D///model/`) whose bundle is selected | `bundle:`: the bundles of the key's dependency closure in the order the extractor loads them (the order is part of the key); context `internalId` (the key's location); atom `live2d.extract_model` (the extractor of `nnnotes export live2d`, run unchanged) | `live2d.model:#`: every file `nnnotes export live2d` writes for the model, `` relative to the model's directory; placed under `/` | -| `spine.skeleton` | a SkeletonDataAsset of a selected bundle, by its stable address `:` (in a bundle of several serialized files the file's tag follows the bundle name, as in `link.addresses`) | `bundle:`: the bundles holding the serialized files the asset's file reaches through its externals; context `object` (the asset's object id); atom `spot.write_skeleton` (the Spine writer of `nnnotes export spot`) | `spine.skeleton:#`: the skeleton (`.json` or `.skel`), its atlases (`.atlas`) and their page images, the bytes `nnnotes export spot` writes into `spine/`; one item for the asset | -| `link.addresses` | global | catalog index, censuses | key -> objects, serialized file -> bundle, bundle -> keys | -| `link.artifacts` | global | the above, the results | object id -> artifact ids | +| `live2d.model`, `live2d.cubism` | a Live2D model key (`Character/Live2D///model/`) a command asks for | `objects`: the object table of what the model reaches ([products](products.md#live2d-models)); depends on `link.addresses` and the `unity.export` tasks it reads | `model.json` (the player's model) and the object table; the Cubism SDK files | +| `room.gltf` | a spot background key (`Spot//Background/`) | `objects`: the object table of what the background reaches ([products](products.md#spots)) | `room.glb` and `room.json` | +| `spot.files` | a spot situation key (`Spot///`) | `objects`; `master`: the spot's master record | `spot.json` and the Spine files | +| `live.music` | a music id (`MasterLiveMusic`) | `objects`; `acb:` and `sheets`: the BGM's cue sheet and its `cri.audio` files ([products](products.md#live-sounds)); `master`: the music's master record; parameter `audio` (`aac`) | `music.json` and the BGM in the web format | +| `live.sounds` | the note sound sets offered: `default` or `NoteSePatternId=` | `objects` (the sound settings of the APK's built-in files); `acb:` and `sheets` of each cue sheet; `apk:assets/Cri/Sound/Sirius.acf`; `master` | `sounds.json` and the decoded sounds | +| `live.notes` | `default` | `objects`: the object table of the note prefabs and the lane effect settings ([products](products.md#note-highway)); `master`: the settings' master record | `notes.json` and the object table | +| `live.noteskin` | a note skin (`skin001`) | `objects`: the object table of the skin and the flick views | `noteskin.json` and the object table | +| `live.noteeffect` | a note effect set (`effect001`, `effect001Light`) | `objects`: the object table of the effect settings | `noteeffect.json` and the object table | +| `live.scene` | `default` | `objects`: the object table of the scene, the bands' start timelines and backgrounds, the lane skins, the UI assets and the built-in graphics settings ([products](products.md#live-scene)); `master`: the master record of its settings, lane skins and bands | `scene.json` and the object table | +| `live` | a live: `/[/]` | `objects`: the object table of the chart and the jacket ([products](products.md#lives)); `master`: the live's master record and the manifest paths of its parts | `live.json`, `chart.json`, `notes.json`, `notes.mirror.json` and the object table | +| `live.ui` | `//` | `objects`: scene, fonts, jacket and localization settings; `master`; `jacket.objects`; `font-bundles`; `apk:AndroidManifest.xml`; explicit `font:` inputs in open mode; parameter `fonts` (`open` or `game`) | `ui.json`, glyph pages, jacket and text shaders ([products](products.md#live-start-ui)) | +| `story.sound` | a cue sheet name | `objects`; `acb:` and `sheets`: the cue sheet and its `cri.audio` files ([products](products.md#stories)); parameter `audio` (`aac`) | `cues.json` (each cue's nonempty CRI `categories` included) and the cues' sounds in the web format | +| `story.crilips` | `default` | `objects` (empty); `apk:lib/arm64-v8a/libcri_lips_unity.so` | `crilips.json` and `crilips.bin` | +| `story.scene` | `default` | `objects`: the object table of the ADV scene, its prefabs and settings and the built-in graphics settings ([products](products.md#stories)) | `scene.json` and the object table | +| `story.asset` | a catalog key a story loads | `objects`: the object table of what the key reaches | `asset.json` and the object table | +| `story.video` | a video key `Cri/Video/` | `objects`: the object table of the key; `usm:` and `movies`: the key's USM and its `cri.movie` files with their facts ([products](products.md#stories)); the `cri.movie` task takes `format` `webm` | `video.json` and `movie.webm` | +| `story.chat` | `default` | `objects`: the object table of the shared chat shards | `chat.json` | +| `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `objects`: UI, font and chat-window objects; `ui.objects`: the retained UI closure; `master`: localized `MasterText` rows; `font-bundles`; `apk:AndroidManifest.xml` ([products](products.md#stories)) | `ui.json` (text styles and chat status texts) and the UI object table | +| `story.fonts` | `/` | `objects`; `episode`: the parsed episode snapshot; `master`: localized texts; `font-bundles`; `apk:AndroidManifest.xml`; `font:` and `font:emoji` for parameter `fonts: open` ([products](products.md#stories)) | `fonts.json`, glyph pages and the text shader objects; `fonts: game` uses TMP assets | +| `story.simpleui` | a client language | `objects`: font and simple UI objects; `ui.objects`: retained UI closure; `master`; `font-bundles`; `apk:AndroidManifest.xml` | `ui.json`, localized text styles and the UI object table | +| `story.host` | `home/` or `afterlive` | `objects`: host UI, capture camera and scene; `master`: host row and related product paths | `host.json` with prefab references, layout, opened sequences and home background state | +| `story` | a `MasterAdv` id | `objects`: the object table of the episode's command list, its shards and the ADV player settings ([products](products.md#stories)); `master`: the episode's master record | `episode.json` and `story.json` | +| `link.addresses` | global | catalog index, censuses (the built-in files' too) | key -> objects (a scene key: the objects of the scene's file outside a hierarchy), Resources.Load path -> objects, built-in selector (class, MonoBehaviour script class) -> objects, serialized file -> bundle or built-in file, bundle -> keys | | `view.` | a catalog (`main`) | `master:
` per master table it reads, `rules` (the view's rule and the resolvers it uses), `addresses` (the part of the address table it read: the keys looked up and the prefixes listed); no parameters, atoms or context | `view.:#view` (facts: rows, entries, gaps, unreferenced) | | `view.voices` | a catalog (`main`) | `master:
` per master table its rules read, `rules`, `addresses`, `sheets` (each cue sheet the `cri.audio` results are known by that a voice names or the rules' prefixes list: its task and ACB content id), `acb:` (the ACB of each decoded one), `episodes` (per story episode and part, the `unity.export` artifact holding the object its key names), `adv:` (those contents); context `audio` (the keys of those `cri.audio` results); runs after `unity.export` and `cri.audio` | `view.voices:#view` (facts: rows, entries, gaps, unreferenced streams) | @@ -38,7 +58,9 @@ gzip input and Japanese remote asset placeholders). ## Converters (`unity.export`) -`unity.export` reads one bundle alone and gives every object of it exactly one item status: `exported`, +The converters are the library `nnnotes.unity.convert`, one module per group of classes; `unity.export` +(`nnnotes.pipeline.objexport`) runs them over one unit of a bundle read alone and gives every object of the unit +exactly one item status: `exported`, `contained` (part of another artifact), `generic` (typetree JSON because the specialized form could not be made; the reason says why), `unsupported` (a class the stage does not convert; its typetree JSON is written as well) or `failed` (not even the typetree JSON could be made). A converter's documented limit gives the generic typetree JSON @@ -46,25 +68,90 @@ with its reason code; any other error of a converter gives it with `generic.erro | Class | Converter | Artifacts | Atoms | Notes | |---|---|---|---|---| -| Texture2D | `tex.png/1` | `image`: PNG of the first mip level (HDR ASTC: `.astc`) | `texture.decode`, `png.encode`, `astc.container` | further mip levels as facts; a texture without pixels: `empty.texture` | -| Sprite | `sprite.png/1` | `image`: PNG on the sprite's rect (packing undone, tight mask applied), `meta`: JSON | `texture.decode`, `sprite.crop`, `png.encode`, `mesh.arrays` | the texture is decoded once per task; a sprite whose SpriteAtlas is in another bundle gets its `meta` here (with its mesh and object facts) and its `image` from `sprite.crop` | -| Mesh | `mesh.glb/1` | `mesh`: binary glTF in the mesh's local space | `mesh.arrays`, `gltf.write` | see `gltf.write` below; limits of the skin and blend shapes as issues of the artifact | -| GameObject (a root) | `prefab.json/1` | `prefab`: the hierarchy's nodes (path, path ids, transform) with their components' fields | | the objects below it are `contained`; a root of a scene's file names its scene | -| Material | `material.json/1` | `json`: shader, keywords, textures, floats, colours | | | -| Shader | `shader.json/1` | `json`: the parsed form (the bytes of `nnnotes export shader`'s `.json`), `program:/sp

__`: each compiled sub-program as stored (`.glsl`, `.metal`, `.vkprog`), `index`: the variants (platform, subshader, pass, stage, GPU program type, keywords, program role), `typetree`: the typetree JSON | | a shader without a parsed form: its typetree with `generic.error` | -| AnimationClip | `clip.json/1` | `json`: the Mecanim clip data with its bindings resolved in the bundle | | a legacy or compressed clip: `generic.clip.legacy` | -| AnimatorController | `controller.json/1` | `json`: layers, state machines, parameters | | an AnimatorOverrideController: `generic.controller.override` | -| MonoBehaviour (not in a hierarchy) | `mono.json/2`, `cri.usm/1` | `json`: `$script` (assembly, namespace, class) and the typetree; a cue sheet asset also `acb`, a movie asset holding its USM also `usm` (see below) | `sniff` | the class from the task's script table | -| TextAsset | `data/1` | `data`: the stored bytes | `sniff` | extension from the container path, else from the content | -| Font | `font/1` | `font`: the font file | `sniff` | | -| MonoScript | `scripts.json/1` | `#scripts.json`: every MonoScript of the bundle | | | -| AssetBundle | `bundle.json/1` | `#bundle.json`: serialized files, externals, the AssetBundle objects | | | -| any other class | `generic.json/1` | `json`: the typetree | `sniff` | | - -A task's key includes the atoms `reader` and `sniff`, the converter `generic.json`, and the converters (with their -atoms) of the classes the bundle's census lists, so a converter's version bump runs again exactly the bundles holding -its classes. `classes` (a list of class names) limits a task to the objects of those classes and the objects their -hierarchies contain. +| Texture2D | `tex.png/2` | `image`: PNG of the first mip level (HDR ASTC: `.astc`), `json`: the typetree without `image data` (settings, colour space, mip count, stream data) | `texture.decode`, `png.encode`, `astc.container` | further mip levels as facts; a texture without pixels: `empty.texture` | +| Sprite | `sprite.png/3` | `image`: PNG on the sprite's rect (packing undone, tight mask applied), `json`: the typetree, geometry, render data and image (see below) | `texture.decode`, `sprite.crop`, `png.encode`, `mesh.arrays` | the tasks of a bundle share the textures it decoded last; a sprite whose SpriteAtlas is in another bundle gets its `json` here (with its geometry and object facts) and its `image` from `sprite.crop` | +| Mesh | `mesh.glb/3` | `mesh`: binary glTF in the mesh's local space, `arrays`: the vertex channels, submeshes and skin as JSON (below), `json`: the typetree without the vertex, index and compressed mesh data | `mesh.arrays`, `gltf.write` | see `gltf.write` below; limits of the skin and blend shapes as issues of the artifact | +| GameObject (a root) | `prefab.json/3` | `json`: the hierarchy's nodes (path, path ids, transform) with their components' fields | | the objects below it are `contained`; a root of a scene's file names its scene | +| Material | `material.json/2` | `json`: shader, keywords, textures, floats, colours | | | +| Shader | `shader.json/2` | `json`: the parsed form (the bytes of `nnnotes export shader`'s `.json`), `program:/sp

__`: each compiled sub-program as stored (`.glsl`, `.metal`, `.vkprog`), `index`: the variants (platform, subshader, pass, stage, GPU program type, keywords, program role), `typetree`: the typetree JSON | | a shader without a parsed form: its typetree with `generic.error` | +| AnimationClip | `clip.json/3` | `json`: the Mecanim clip data and its bindings as stored (see below) | | a legacy or compressed clip: `generic.clip.legacy` | +| AnimatorController | `controller.json/3` | `json`: name, clips (`$ref`), layers, state machines, parameters, default values | | an AnimatorOverrideController: `generic.controller.override` | +| MonoBehaviour (not in a hierarchy) | `mono.json/3`, `cri.usm/1` | `json`: `$script` (assembly, namespace, class) and the typetree; a cue sheet asset also `acb`, a movie asset holding its USM also `usm` (see below) | `sniff` | the class from the task's script table | +| TextAsset | `data/2` | `data`: the stored bytes, `json`: the typetree without `m_Script` | `sniff` | extension from the container path, else from the content | +| Font | `font/3` | `font`: the font file, `json`: the typetree without `m_FontData` | `sniff` | | +| any other class (a MonoScript, an AssetBundle, ...) | `generic.json/3` | `json`: the typetree | `sniff` | an object that can be read only in part (see below): that part, `unsupported.typetree` | + +A Mesh's `arrays` is a canon/1 JSON document in the mesh's local space, keys in this order: `positions` (per vertex +`[x, y, z]` as stored), `normals`, `tangents`, `colors`, `uv0` to `uv7` (per vertex the channel's components as floats, +keeping the channel's dimension; null when the mesh stores no such channel), `submeshes` (one per primitive, in order: +`topology` (`triangles`, `lines`, `line_strip` or `points`) and `indices`, triangles as `[a, b, c]` triples, other +topologies a flat list, null without an index buffer; indices are absolute vertex indices), and `joints` (per vertex 4 +integers) and `weights` (per vertex 4 floats), both null without a skin. Bind poses are not repeated here: they stay in +the `json` typetree as `m_BindPose`. A mesh without vertices has `positions` as an empty list. + +Every object a converter takes has a `json` artifact or is contained in another object's. Each item lists in +`refs` the objects its documents reference (by object id, other bundles' objects included, the objects of its own +prefab left out): the objects reachable from an object follow from the items alone. The census of a bundle lists the +same before anything is converted, as a superset: per object `refs`, every object its typetree references, and +`unit`, the root of the hierarchy whose prefab holds it. + +A sprite's `json` holds `sprite` (its typetree), `mesh` (its geometry decoded: `positions` in the sprite's units, +`submeshes` of triangles, `uv0` when stored), `renderData` (`source`: `renderData` for the sprite's own render +data, `atlas` for its SpriteAtlas' entry; `atlas`; and that render data's `texture`, `alphaTexture`, +`secondaryTextures`, `textureRect`, `textureRectOffset`, `atlasRectOffset`, `uvTransform`, `downscaleMultiplier`, +`settingsRaw`) and `image` (`packed`, `packingMode`, `packingRotation`, `width`, `height`). The texture coordinate +of a position `p` is `((p.x * uvTransform.x + uvTransform.y) / width, (p.y * uvTransform.z + uvTransform.w) / +height)` of the render data's texture. + +### The APK's built-in files + +With `[paths] apk` set, the player's own Unity files under `assets/bin/Data/` of the APK are subjects of +`unity.census` and `unity.export` like the bundles, named `apk/`: + +| Subject | Holds | +|---|---| +| `apk/data.unity3d` | `globalgamemanagers` (player, graphics and quality settings, the resource table), `globalgamemanagers.assets` (render pipeline assets, renderer data and features, post-process data, shaders), `level0`, `resources.assets`, `sharedassets0.assets`, `unity_builtin_extra` (built-in shaders) | +| `apk/datapack.unity3d` | `resources.assets` in split builds | +| `apk/Resources/unity default resources` | the serialized file of the engine's default meshes, textures, shaders and font | + +Their objects have the ids of any serialized file's (`unity_builtin_extra:12`, `unity default resources:10`), so a +bundle's reference into them names an object these tasks export, and `link.addresses` maps their files to their +subjects. Census always covers them; everything and `--select bundle:apk/*` export them. + +These files store no type trees. Objects of the engine's classes are read with the type trees UnityPy has for the +file's Unity version; MonoBehaviours with the type trees nnnotes ships for the game's classes it reads there +(`nnnotes.unity.scripttypes`, generated by `scripts/gen_typetrees.py`): the render pipeline assets, renderer data, +renderer features, post-process data and the game's settings objects. A MonoBehaviour takes the shipped type tree +made for the type hash the file stores for it (the hash of its class's serialized layout), and the read must consume +exactly the object's bytes. The export task's context holds the shipped type trees its MonoBehaviours use, and the +census of a built-in file has the shipped type trees' id among its atoms, so a new shipped tree runs those tasks +again. A MonoBehaviour without one is `unsupported.typetree` with its header (`m_GameObject`, `m_Enabled`, `m_Script`, +`m_Name`) as its typetree JSON; inside a hierarchy its component keeps that header and the prefab records the +issue. PlayerSettings, whose type tree reads the stored layout right up to `m_ActiveColorSpace` (by its stored +type hash), gives the fields up to it. + +### Units + +A unit is what one `unity.export` task converts: a hierarchy (a root GameObject with the GameObjects, Transforms and +components of its prefab: the objects whose census record names it as their `unit`) or an object outside any +hierarchy. Its subject is the stable address of its root object, `:` (in a bundle of +several serialized files the file's tag follows the bundle name, as in `link.addresses`): path ids stay when a +bundle's content changes, so a unit keeps its task across catalog versions. + +A run exports the units holding an object of the selected bundles, the units of the objects the fact "roots" names +(catalog keys, Resources.Load paths, built-in selectors and object ids: a product's roots, [products](products.md)) and every unit reachable +from those through what their objects reference (the census `refs`, across the censuses of the run); with `--only-class` +only the units whose root object is of those classes. A SpriteAtlas reached through a reference adds its textures +but not the sprites it packs: a sprite needs its atlas' textures, not the atlas' other sprites (an atlas the run +starts from adds them all). The selection is not part of any key: a unit's task is the same +whichever request reaches it. + +A task's key includes the atoms `reader` and `sniff`, the converter `generic.json`, the converters (with their +atoms) of the classes of the unit's objects, its bundle, its root and the script entries and shipped type trees its +objects use, so a converter's version bump runs again exactly the units holding its classes. The tasks of one bundle +are one batch: a worker loads the bundle once for them and keeps what depends on the bundle alone (the index of its +objects, its hierarchies, its atlases and the textures it decoded last) until the batch ends. A unit's result +depends on neither the other units of the batch nor their order. Object JSON is canon/1 in the object's own field order: @@ -77,10 +164,25 @@ Object JSON is canon/1 in the object's own field order: - a string whose stored bytes are not UTF-8 is `{"$hex": ""}`; - NaN is `{"$float": "nan"}`, infinities `1e999` / `-1e999`. +An AnimationClip's `json` holds `name`, `sampleRate`, `wrapMode`, `startTime`, `stopTime`, `loopTime`, +`cycleOffset`, `events` (with `objectReferenceParameter` as a reference), `bindings`, `streamed` (`curveCount`, and +`discreteCurveCount` when the clip stores it; `frames`: `[time, [[curve, c0, c1, c2, c3], ...]]`, a curve's value at +`t` being `((c0*x + c1)*x + c2)*x + c3` with `x = t - time` from its last key at or before `t`), `dense` +(`curveCount`, `frameCount`, `sampleRate`, `beginTime`, `samples`), `constant` and `pptrCurveMapping` (references). +Curve indices run over the streamed curves, then the dense ones, then the constant ones; each float binding takes +`curves` of them (1 when the binding has no `curves`) in `bindings` order. A binding is the clip's GenericBinding as +stored: `pathCrc` (crc32 of the animated object's path relative to the GameObject whose Animator plays the clip), +`attributeCrc`, `typeID` with its `class` when it is a known class id, `customType`, `pptr`, `int`, +`serializeReference`; a binding that names a script has `script` (the MonoScript reference) and `scriptClass` +(its entry in the task's script table, `null` when the table lacks it), and a Transform binding (`typeID` 4) of +position, rotation, scale or euler angles has `attribute` (`m_LocalPosition`, `m_LocalRotation`, `m_LocalScale`, +`localEulerAnglesRaw`) and `curves` (3, 4, 3, 3). Paths and the attributes of other components are named by the +hierarchy that plays the clip, which the clip alone does not give. + A cue sheet held in a bundle is a MonoBehaviour in one of two layouts; either gives the artifact `#acb` (kind `cri.acb`, facts: `cueSheet`, `layout`), which `cri.audio` decodes: -- a SplitAcbData (fields `_cueSheetName`, `_chunks`): its chunk TextAssets (of the same bundle, exported as well) joined +- a SplitAcbData (fields `_cueSheetName`, `_chunks`): its chunk TextAssets (of the same bundle, units it references) joined in order, every byte XORed with the one mask that gives the ACB signature `@UTF` (layout `split`; its refs name the chunks); - an asset named like the sheet whose `implementation` managed reference is a CriSerializedBytesAssetImpl: its @@ -89,11 +191,11 @@ A cue sheet held in a bundle is a MonoBehaviour in one of two layouts; either gi A movie asset (CriWare.Assets `CriManaUsmAsset`) whose `implementation` is a CriSerializedBytesAssetImpl holds its USM the same way: its `data.data` bytes, whatever their size, are the artifact `#usm` (kind `cri.usm`, facts: -`movie`, the asset's name), which `cri.movie` converts. Converter `cri.usm/1` does this; it is in the key of a bundle -only when the bundle's script context names that class, so no other bundle's key changes with it. +`movie`, the asset's name), which `cri.movie` converts. Converter `cri.usm/1` does this; it is in the key of a unit +only when the scripts of its objects name that class, so no other unit's key changes with it. -`sprite.crop` makes one task per SpriteAtlas that sprites of other bundles use (subject `:`). Its inputs are the atlas' JSON, the `meta` of each such sprite and the images of the +`sprite.crop` makes one task per SpriteAtlas that sprites of other bundles use (subject: the `unity.export` +subject of the atlas' unit). Its inputs are the atlas' JSON, the `json` of each such sprite and the images of the atlas textures they use; it writes `#image` with the same bytes `unity.export` writes for a sprite whose atlas is in its own bundle. @@ -101,13 +203,13 @@ whose atlas is in its own bundle. `cri.audio` and `cri.movie` take their subjects by content: a task is `cri.audio:` or `cri.movie:`, so an ACB or a USM stored both as a raw file and in a bundle, or in several bundles, -is decoded once. Raw files are told apart by their first bytes (`@UTF` ACB, `AFS2` AWB, `CRID` USM). Both read the -HCA keycode from the `boot` input when the task runs; the key itself is never part of a task, a record or a log. -Each task has one item (object: the task id; class `ACB` or `USM`): `exported`, or `unsupported` with a reason code. -Their artifacts belong to no object; the `original` layout places them under each name the stage gives the content: -the catalog keys that load a raw file, `Cri/Sound/` for a cue sheet held in a bundle (the sheet's name -when the catalog has no such key), the keys of its bundle that end in the movie asset's name for a movie held in a -bundle. +is decoded once. Raw files are told apart by their first bytes (`@UTF` ACB, `AFS2` AWB, `CRID` USM), read once per +content and recorded in the depot, so a later plan reads no raw file it has seen. Both read the HCA keycode from the +`boot` input when the task runs; the key itself is never part of a task, a record or a log. Each task has one item +(object: the task id; class `ACB` or `USM`): `exported`, or `unsupported` with a reason code. Their artifacts belong +to no object; the `original` layout places them under each name the stage gives the content: the catalog keys that +load a raw file, `Cri/Sound/` for a cue sheet held in a bundle (the sheet's name when the catalog has no +such key), the keys of its bundle that end in the movie asset's name for a movie held in a bundle. | Stage | Parameters | Atoms | Artifacts (`#`) | |---|---|---|---| @@ -124,20 +226,21 @@ and multi-audio paths are tested on synthetic movies only. The ids of the external tools are `-+sha256./` (the version as `vgmstream-cli -V` / `ffmpeg -version` report it): another build of a tool runs the tasks that use it again, and a task described for another build is refused as incompatible. The tools are `[paths] vgmstream` and `[paths] ffmpeg`, -else found on PATH. The `boot` input is the APK's `assets/bin/Data/data.unity3d` (`[paths] apk`), stored once. +else found on PATH. The `boot` input is the APK's `assets/bin/Data/data.unity3d` (`[paths] apk`), the input of +the `apk/data.unity3d` tasks. ## Atoms -`nnnotes.pipeline.atoms.ATOMS[name]` is the reference implementation of an atom: its id, its function and its cost estimate. +`nnnotes.unity.atoms.ATOMS[name]` is the reference implementation of an atom: its id, its function and its cost estimate. The id names the libraries whose behaviour the output depends on, with their installed versions, and the revision of the nnnotes code (`pillow-12.1.1/1`); `nnnotes/` when no library's behaviour is involved. Task keys include the ids of the atoms a task uses, so upgrading a library or changing an atom re-runs exactly the tasks that use it. | Atom | Reference implementation | Id | Does | |---|---|---|---| -| `reader` | `nnnotes.pipeline.atoms.reader.open_bundle` (UnityPy) | `unitypy-/1` | one bundle loaded alone from its bytes: serialized files, objects, raw bytes, typetrees, container, externals, and the inputs of `texture.decode` and `mesh.arrays` (resource files are read from inside the bundle only) | +| `reader` | `nnnotes.unity.atoms.reader.open_bundle` (UnityPy) | `unitypy-/1` | one bundle loaded alone from its bytes: serialized files, objects, raw bytes, typetrees, container, externals, and the inputs of `texture.decode` and `mesh.arrays` (resource files are read from inside the bundle only) | | `texture.decode` | UnityPy `Texture2DConverter.parse_image_data` | `unitypy-+texture2ddecoder-+astc-encoder-py-+pillow-/1` | the first mip level, top row first, as UnityPy's `Texture2D.image` | -| `png.encode` | Pillow | `pillow-/1` | PNG bytes; level 6 (the default) gives Pillow's default bytes, the ones the other commands write | +| `png.encode` | Pillow and its native zlib codec | `pillow-+pillow.zlib-/1` | PNG bytes; level 6 (the default) gives Pillow's default bytes, the ones the other commands write | | `astc.container` | nnnotes | `nnnotes/1` | the standard `.astc` file (16-byte header, first mip level's blocks) | | `sprite.crop` | nnnotes (a port of UnityPy's `SpriteHelper`) | `pillow-+numpy-/1` | a sprite's image from its texture's image: texture rect, packing rotation / flip, alpha texture, tight mask or mesh drawing; optionally on the sprite's full rect | | `mesh.arrays` | nnnotes | `numpy-/1` | every vertex channel with its stored type and component count, primitives per submesh (base vertex applied), skin | @@ -169,7 +272,7 @@ The current implementations: |---|---| | `reader` | UnityPy | | `texture.decode` | UnityPy (`Texture2DConverter`) | -| `png.encode` | Pillow, zlib level `png.level` (6) | +| `png.encode` | Pillow, native zlib codec identity, zlib level `png.level` (6) | | `astc.container` | nnnotes | | `sprite.crop` | nnnotes | | `mesh.arrays` | nnnotes | @@ -205,6 +308,7 @@ The codes of items that are `generic`, `unsupported` or `failed`, and of limits | `unsupported.usm.codec` | a USM video or alpha stream in a codec other than VP9, H.264 or MPEG-1, an audio stream in a codec other than ADX or HCA, or an H.264 stream without a frame rate | | `unsupported.usm.stream` | a USM stream of a kind other than video, alpha, audio or subtitles | | `unsupported.usm.subtitle` | USM subtitles that are not records, or whose text is not UTF-8 (the movie's other artifacts are made) | +| `unsupported.crilips.version` | a CRI Lips library of a version whose analysis data nnnotes cannot locate | | `unsupported.class.AudioClip` | an AudioClip | | `unsupported.class.VideoClip` | a VideoClip | | `unsupported.class.MovieTexture` | a MovieTexture | @@ -213,7 +317,47 @@ The codes of items that are `generic`, `unsupported` or `failed`, and of limits | `no_data.sprite` | a sprite whose render data names no texture, or whose texture has no image | | `unsupported.sprite.external_texture` | a sprite whose render data texture (not an atlas) is in another bundle | | `generic.error` | a converter failed on the object (the message says how); its typetree JSON instead | +| `unsupported.typetree` | an object of a file without type trees read in part: a MonoBehaviour without a shipped type tree of its class (its header), a class whose type tree fits only up to a field (those fields) | | `failed.read` | the object's typetree could not be read | | `failed.export` | the object's typetree JSON could not be made | | `source.absent` | a catalog entry whose file is not there (an APK entry without a file) | | `source.error` | an input that could not be fetched | + +## ui.pack + +`ui.pack` assembles the selected APK UI key from its object table, including referenced font objects. It writes `ui.json` with native roots, prefab/controller lists and named animation bindings. The product layout groups its manifests into the UI library index. See [UI libraries](ui.md). + +## site.asset + +`site.asset` prepares one content-addressed file for a static site. Its subject is `.`; +its input role is `source`. The task key includes the input content, transformation parameters and the installed +encoder implementation. Input names and storage locations do not affect the result. + +- `encoding`: `gzip` (default), `br` or `none`. A compressible file uses the encoded form only when it is smaller. +- `split`: JSON byte threshold (default 524288); `0` keeps every file whole. Objects with at least two supported + top-level keys are split into value files that reconstruct the exact minified text. +- `omit`: JSON key paths to remove before minification, for example `[["metadata", "description"]]`. The default + empty list preserves every key. Paths are explicit; native component fields retain their names and values. + +The result's `entry.json` is a product file entry, either `{asset, size, stored?}` or `{parts, size}`. Asset paths +use the decoded content id; compressed bytes have their own depot content id. The result records both byte forms, +and layout entries select the stored form. Replaying a task reuses its result; changing a parameter or encoder +version gives a different task key. + +## site.references and site.manifest + +Both document stages take `source` and `spec` JSON inputs and emit `document.json`. `site.references` applies an +explicit path-to-manifest map to declared reference fields and selects the requested language groups. +`site.manifest` applies transformed file entries to a product while checking that its logical paths, object ids +and file roles agree with the source. It also records the relative site root and optional default language. + +The snapshot assembler consumes these task results to build immutable product graphs; see +[site snapshots](products.md#site-snapshots). + +## site.readset, site.selection and site.index + +`site.readset` freezes the player's sources and product input tree together with a request (entry path, plan/full mode, settings and variant manifests). The Node/V8 version, platform and architecture are implementation atoms. Its `readset.json` contains sorted logical file paths and object roles plus deterministic simulation counters. A result requires the planned state or the end of the full chart simulation. + +`site.selection` takes a source product and an explicit read set. It retains structured documents, their image/audio/WebGL2 dependencies and additional read binary roles, preserving object IDs and metadata. With audio disabled it omits sound streams and marks the entry product accordingly. `site.manifest` can record variant paths and supported Live options. + +`site.index` takes the complete `nnnotes.site/1` catalog and emits chart, model and story listings, alias mappings and the catalog itself. Equal regional products with equal listing facts share aliases. The site layout traverses every retained product dependency before publishing and recording its successful run. diff --git a/docs/ui.md b/docs/ui.md index 57c1686..8c29bb3 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1,66 +1,45 @@ -# Serialized UI libraries (experimental) +# UI libraries -`nnnotes ui` exports an offline prefab library for the optional `ournotes-player/ui` preview module. It reads the -embedded Addressables catalog and bundle closure of the APK set you supply; it does not contact a game API. Keep -outputs outside the nnnotes and ournotes-player source repositories. The export contains game data that these -repositories and their packages do not redistribute. +`nnnotes export ui` assembles an offline UI library for `ournotes-player/ui`. The supplied APK set provides the +catalog keys, native prefab fields, animations, sprites and fonts. The depot stores their converted objects and +product results; the output directory is a product layout. ```sh -nnnotes --apk /path/to/base.apk ui --prefix EmbUI/Prefab/ --limit 10 -o /path/to/ui-data -nnnotes --apk /path/to/base.apk ui --key YOUR_EXACT_APK_KEY -o /path/to/ui-data +nnnotes --apk /path/to/base.apk export ui --prefix EmbUI/Prefab/ --limit 10 -o /path/to/ui-data +nnnotes --apk /path/to/base.apk export ui --key YOUR_EXACT_APK_KEY -o /path/to/ui-data ``` -`--key` is repeatable. Without it, `--prefix` defaults to `EmbUI/`. `--no-dependencies` omits additional root -prefabs and AnimatorControllers; `--force` re-exports the selected keys. A failed key is retained as an explicit -index entry and makes the command exit with status 1. Existing unrelated entries of the same input stay present. +`--key` is repeatable. Otherwise `--prefix` selects APK asset keys and defaults to `EmbUI/`; `--limit` takes the +first keys in sorted order. `--stored-catalog` selects a saved catalog version. The common `--workers`, `--link`, +`--dry-run` and `--explain` options apply. The output describes the complete selection of that invocation; +repeated invocations reuse unchanged depot tasks. Reports under `_reports/` describe coverage and failures. -## Inputs and regions +## Input configuration -The command uses the same `ApkSet` reader as the existing extractors: one APK, base.apk with adjacent splits, a -directory of splits, or an APKS/XAPK archive. Explicit `--apk` wins over the selected region's APK configuration. -The configured cache and bundle decryption settings are used when needed. Readable unencrypted local bundles do -not require a key. Default Unity resources are read from the selected APK set through the existing player reader. +Configure the APK set and depot as described in [configuration](configuration.md). APK input may be a single APK, +base.apk with adjacent splits, a split directory, or an APKS/XAPK archive. Region selection chooses its configured +APK. Keep each library's output directory associated with its intended input configuration. -```sh -nnnotes --region jp ui --prefix EmbUI/Prefab/ --limit 10 -o /path/to/jp-ui-data -``` - -For JP, configure the actual JP APK set as described in [jp.md](jp.md). Do not point a JP region at an international -APK: this command exports the APK selected by the configuration, not a replacement fetched from that region. -Keep international and JP libraries in separate output directories. Their keys, classes and fonts can differ. - -Current real UI smoke checks used international client 1.0.1 (versionCode 25). A synthetic split-APK test verifies -the JP command/configuration path; it is not a full JP UI export or pixel-fidelity verification. Existing JP -catalog, chart, story and model validation is described separately in [jp.md](jp.md). - -## Files and resume - -`index.json` has `schema: 1`, `format: "ournotes-ui-library"`, and three lists: +## Product layout -- `assets`: selected catalog keys, IDs, kind, export status, pack file and SHA-256; failures contain an error type - and message rather than a pack file. -- `embedded`: additional root GameObjects found in a selected key's dependency closure. -- `controllers`: AnimatorControllers found in that closure, keyed by actual serialized object identity. +`index.json` has `format: "nnnotes.ui-library/1"`: -Pack files under `packs/`, `embedded/` and `controllers/` have `format: "ournotes-ui-pack"`, a `document`, -`resources` and `resourceBase: "../"`. Hierarchy nodes retain serialized rectangles, transforms, active flags, -component fields and preorder paths. `nodeId` and reference `nodeId` values distinguish same-named instances; -controller references carry a matching ID. Negative intermediate rectangle sizes are preserved. +- `assets`: selected catalog keys, display names, stable key IDs, root object IDs and product manifest paths. +- `embedded`: referenced prefab roots, identified by serialized object ID. +- `controllers`: referenced AnimatorController objects, with their object IDs and containing product manifests. -Resources contain Sprite metadata and texture/font paths. PNGs and embedded TTFs are written under `textures/` -and `fonts/`. Each used VibeMO numeric font face has separate ASCII glyph metrics in `fontMetricsByAsset`; the -legacy `fontMetrics` field names the first one. A metrics file's texture is relative to that file when its -`textureBase` is `"metrics"`. Rotated packed sprites are decoded to standalone RGBA images. +Each `uipacks/.json` is a `nnnotes.product/1` manifest for `ui.pack`. Its `files` map contains `ui.json`, and its +`objects` map lists each retained object's files as content-addressed assets. Texture images, embedded Font data, +TMP font records, clips, controllers and prefabs use the shared object conversion layer. -The index records hashes of every pack and its texture/font/metrics files, as well as dependency IDs. A resumed -key is skipped only when these files and its dependency records still validate. Missing or corrupt files are -re-exported. Provenance records hashes of the APK catalog and effective APK-set member metadata, the manifest -version and configured region; it contains no credentials or server addresses. A different input is rejected -before updating an existing library. The index is replaced atomically after each key. +`ui.json` has `format: "nnnotes.ui-pack/1"`, the catalog `key`, `roots`, `prefabs`, `controllers`, display `entries`, +`shaders`, and per-prefab animation `bindings`. Nodes preserve their serialized transforms, rectangles, active +flags, component type/script/fields and path IDs. References use object identity or a local hierarchy target. +Clips retain their raw curves; bindings name each curve's path and property in the Animator's hierarchy. -## Scope +UI products include the fonts referenced by their object graph. Product stages opt into this traversal with +`INCLUDE_FONTS`; language-specific story products select fonts through their own products. -This is inspectable serialized data, not an implementation of every game Presenter. The preview implements a -subset of layout, graphics, component binding and animation. Exported particle, shader/material, localization, -video, camera and custom component fields do not imply that the preview executes them. `runtime_verified` stays -false. The companion player documentation lists rendering limits and the optional viewer. +See [ui-pack schema](schemas.md) and [products](products.md) for the format contracts. The preview module provides +Canvas 2D inspection, animation sampling and editable copies of prefab state. It supports a defined subset of +runtime behavior; its API documents the rendering and animation limits. diff --git a/docs/validation.md b/docs/validation.md index 63e7ea7..dcd1008 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -1,51 +1,68 @@ # Validation -The test suite runs on synthetic inputs, so it needs no game data. The experiments on this page check nnnotes -against a whole game version, which you supply yourself: each one names what it checks, how to run it with your own -configuration, and the result for the version named. Only commands and checks are listed. The outputs stay with -whoever runs them. +Validation has three distinct scopes: synthetic contract tests, checks over generated files, and browser playback. +Use the checks that match the output and consumer you are working on. -The results below are for the Traditional Chinese release, client 1.0.1, catalog language `zh-Hant`, with its master -data. The [Japanese release](jp.md#validation-and-current-limits) has its own section. +## Synthetic tests -## Setup +```sh +pip install -e '.[test,fonts,remote,deck]' +python -m pytest +python -m ruff check . +python -m pyright +``` + +The tests exercise object conversion, product assembly, language selection, regional aliases, reference closure, +task identities and retained layouts. Directory remotes exercise sharing between depots. S3 protocol tests use +botocore stubs to check request parameters, pagination, deletion batches and errors without an account. +Publication tests inject failures between file replacements and check recovery and index ordering. + +Every registered output and site stage has a golden record. With the dependency versions in +`.github/goldens-constraints.txt`, run: ```sh -nnnotes config check # every setting the commands below need is valid -nnnotes master download --latest -o work/master-bin --json > work/download.json -nnnotes master decode work/master-bin -o work/master --json > work/decode.json -nnnotes config set paths.master work/master +python -m pytest tests/pipeline/test_goldens.py tests/pipeline/test_objexport.py ``` -The lists of subjects come from the master data and the catalog: +Set `GOLDENS_STRICT=1` for a matching pinned environment so a dependency mismatch fails the check. +The records include native codec identities; see [determinism](determinism.md). + +## Check stored content ```sh -python -c "from nnnotes.source.master import table; print(*sorted(r['_id'] for r in table('work/master', 'MasterAdv')), sep='\n')" > work/episodes.txt -nnnotes catalog keys --prefix Character/Live2D/ --limit 0 > work/live2d-keys.txt +nnnotes depot verify +nnnotes depot verify --quick +nnnotes depot gc --dry-run ``` -## Experiments - -| Experiment | How to run it | Result (client 1.0.1, zh-Hant) | -|---|---|---| -| Master data | `master download` checks every file against the SHA-256 of `MasterManifest.json`; `master decode` reports each table it could not decode under `failed` | every file verified, every table decoded | -| ADV episodes | `export episode ID` for every id of `episodes.txt` | 946 / 946 exported | -| Stories | `export story ID -o out/stories/ID` for every id, all with the same models directory; then read every JSON file and resolve every path `story.json` and the files it indexes name | 946 / 946 exported; every JSON file parses, every named file, model and cue exists | -| Story reproducibility | export the stories that use frames, effects, post effects, stills, chats, talk windows or videos twice into two directories and compare them byte by byte | 235 such episodes; the two exports are equal except the models directory path in `story.json` | -| Live2D models | `export live2d KEY` for every model key of `live2d-keys.txt` (the keys ending in `/model/`) | 239 / 239 exported, 185 of them used by stories | -| CRI audio | `export audio CUE_SHEET` for every cue sheet under `Cri/Sound/` | 681 / 681 decoded | -| Lives | `export live MUSIC_ID --difficulty D` for every music and difficulty with a `MasterLiveMusicScore` row | 336 / 336 exported | -| Spots and rooms | `export spot ID` for every `MasterHomeSpot` row and `export room KEY` for every room background prefab; validate every `.glb` with the [Khronos glTF-Validator](https://github.com/KhronosGroup/glTF-Validator) | 39 / 39 spots, 20 / 20 rooms; every reference resolves; every glb has 0 validator errors | -| Site, one region | `site build --all --all-live2d --all-stories --story-languages en --no-audio` twice into two empty directories, then compare them | 336 charts, 239 models, 946 stories; no failures file; the two sites are byte-identical | -| Site, every region | `site build --all --all-regions --format aac` twice into two empty directories, then compare them | 340 charts of 85 songs from the regions' current master data, five languages; no failures file; the two sites are byte-identical | -| Regions | for each region and language, `catalog keys --limit 0` and the catalog's bundles; compare the master tables a chart build reads across regions | the regions serve the same catalog and bundles for a language with the same keys; the chart tables are equal across the three regions; the text tables have all five languages | - -## Checks of every run - -These hold for every command and are checked by the test suite on synthetic data: - -- A key, id or model that the catalog or the master data does not have stops the command with exit status 2 before - any bundle is fetched or file written. -- The documents of an asset export run (plan, tasks, results, artifacts, run manifest, layout, coverage, failures, - cost) and the music data file validate against their [JSON schemas](schemas.md). -- The same inputs give byte-identical outputs ([determinism](determinism.md)). +Full verification checks hashes; quick verification checks sizes. Referenced content held only by the remote +is checked there. A collection dry run reports eligible removals under the configured retention policy. +See [depot](depot.md) for run roots, grace periods and remote collection. + +## Check exported products + +Build a selected site with your configured inputs, then use the compatible player's validator: + +```sh +nnnotes site build --name example -o out/site --story 10462 --fonts game --player node_modules/ournotes-player +node node_modules/ournotes-player/scripts/validate-data.mjs out/site +``` + +The validator checks product schemas, shared objects, file references, chart metadata, unique asset byte counts, +language-specific resources and model dependencies. By default it checks every declared language. +Chart site builds also compare sampled read-set plans with full simulation and fall back to full simulation +when a plan fails or disagrees. See [site](site.md#player-read-sets-and-variants). + +For your own corpus, enumerate subjects from matching master data and catalogs, preserve command reports, +and record the configuration and tool versions used. Compare normalized JSON with references resolved; +compare decoded image pixels and audio samples when encoder differences affect stored bytes. + +## Check playback and hosting + +Serve the output over HTTP, load required runtimes and exercise a chart, a model and the story features in your +selection. Check language changes, seek, completion and browser network errors. Structural validation and rendered +appearance answer different questions. Hosting instructions cover CORS, compression and cache headers in the +[player documentation](https://github.com/empty-sekai/ournotes-player/blob/main/docs/embedding.md). + +An S3 endpoint also has its own permissions, consistency and gateway behavior. Check reads and writes under an +isolated test prefix when configuring a new endpoint, and scope collection to the depot prefix. diff --git a/docs/views.md b/docs/views.md index b3cb362..af73b43 100644 --- a/docs/views.md +++ b/docs/views.md @@ -109,7 +109,7 @@ resolver's name). ## Stages `nnnotes.pipeline.views:stages` gives one stage per view, `view.` (docs/stages.md), run by `nnnotes export assets ---views` and planned by `nnnotes store plan`: +--views` and planned by `nnnotes depot plan`: - Subjects: the catalog subjects of `link.addresses` (the export command has one, `main`), when master data is set and the view is selected (the planning facts `master` and `views`). @@ -132,7 +132,7 @@ resolver's name). ## Views ### `backgrounds` -Profile backgrounds: the rows store full addresses. Table `MasterBackground`. Vars: `path` = `_assetPath`, `thumbnail` +Profile backgrounds: the rows depot full addresses. Table `MasterBackground`. Vars: `path` = `_assetPath`, `thumbnail` = `_thumbnailAssetPath`. Names: `name` = `_nameTextId`, `description` = `_descriptionTextId`. Prefixes: `Image/Background/`, `thumbnail/Background/`. @@ -331,7 +331,7 @@ Song jackets. Table `MasterLiveMusic`. Vars: `jacket` = `_jacketAssetName`. Name ### `login_bonuses` -Login bonus sheets: the rows store full addresses. Table `MasterLoginBonus`. Vars: `sheet` = `_sheetImageAsset`, +Login bonus sheets: the rows depot full addresses. Table `MasterLoginBonus`. Vars: `sheet` = `_sheetImageAsset`, `background` = `_backgroundImageAsset`. Names: `name` = `_nameTextID`. Prefixes: `LoginBonus/`. | Role | Address | Expect | Condition | Required | @@ -399,7 +399,7 @@ Skill icons (a skill names its icon by id). Table `MasterSkillIcon`. Vars: `icon ### `spots` -Home spots: the rows store full addresses. Table `MasterHomeSpot`. Vars: `thumbnail` = `_thumbnailAssetPath`, +Home spots: the rows depot full addresses. Table `MasterHomeSpot`. Vars: `thumbnail` = `_thumbnailAssetPath`, `background` = `_backgroundAssetPath`, `situation` = `_situationAssetPath`. Names: `name` = `_nameTextId`. Prefixes: `Image/Spot/`. diff --git a/mkdocs.yml b/mkdocs.yml index faa526a..a388376 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,9 @@ nav: - Python API: api.md - Contracts: contracts.md - Stages: stages.md + - Products: products.md + - Depot: depot.md + - Formats: formats.md - Views: views.md - Replay data: replay.md - UI library: ui.md diff --git a/pyproject.toml b/pyproject.toml index f91c8a6..8f9e997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,9 +29,11 @@ dynamic = ["version"] test = ["pytest>=8", "ruff>=0.15", "jsonschema>=4.18", "import-linter>=2.1", "pyright>=1.1.414"] fonts = ["fonttools>=4.50", "freetype-py>=2.5", "scipy>=1.13"] docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5", "mkdocstrings[python]>=0.29"] +# a depot remote in S3-compatible object storage ([depot] remote = "s3://...") +remote = ["boto3>=1.35"] # the deck model of music-data: one nnnotes version carries one ournotes-deck release (.github/workflows/deck.yml moves # the pin when ournotes-deck publishes a newer one) -deck = ["ournotes-sim==0.0.9"] +deck = ["ournotes-sim==0.0.12"] [project.scripts] nnnotes = "nnnotes.cli:main" diff --git a/release-notes/v0.3.0.txt b/release-notes/v0.3.0.txt new file mode 100644 index 0000000..e4f52c6 --- /dev/null +++ b/release-notes/v0.3.0.txt @@ -0,0 +1,22 @@ +nnnotes 0.3.0 + +Persistent depots store task inputs, content-addressed results, catalog versions and retained runs, with optional S3-compatible remote storage. Commands reuse verified task results and manage source downloads under a configurable budget and command leases. + +Catalog versions and labels are shared through the remote. Census planning, emitted task descriptions and standalone task results retain the content they need through collection, including completed results from interrupted pipelines. + +Live2D, room, spot, live, story and UI exports use shared object products. Named site builds publish assets, product manifests and indexes in dependency order, preserve unselected regional topics and restore complete layouts from depot records. + +Standalone episode, shader and audio exports also reuse depot tasks. Command records retain accessed results and content. Collection protects recent results and their dependencies for a configurable grace period, and remote verification reads remote-only content without populating the local cache. + +PNG and font task keys include the native compression library identity, so stored results are shared only between matching encoder implementations. + +S3 collection follows paginated listings, batches deletion requests and reports per-object deletion failures. + +Configuration and command interface: +- Set [paths] depot and, when needed, [depot] remote. +- Use depot plan, depot run, depot verify and depot gc to manage stored work. +- Use site build --name NAME -o DIR for a named site and bundle pull -o DIR for source bundle copies. +- Product playback uses ournotes-player 0.2.x. Story and UI data use the documented nnnotes.product/1 formats. +- The deck extra pins ournotes-sim 0.0.12. + +See the configuration, depot, products and site references for command options and data contracts. diff --git a/scripts/atom_eval.py b/scripts/atom_eval.py index 327520e..4ed1e51 100644 --- a/scripts/atom_eval.py +++ b/scripts/atom_eval.py @@ -2,7 +2,7 @@ """Measure candidate implementations of the export atoms against the reference implementations (maintainer tool; not installed, not needed to use nnnotes). -`nnnotes.pipeline.atoms.ATOMS` holds one reference implementation per atom; `NNNOTES_ATOM_=module:attribute` names +`nnnotes.unity.atoms.ATOMS` holds one reference implementation per atom; `NNNOTES_ATOM_=module:attribute` names another one for evaluation (docs/stages.md, "Atoms"). This script gives the numbers a switch is decided on: sample DIR -o FILE a seeded stratified sample of a bundle directory: one bundle of every @@ -11,9 +11,9 @@ m1 DIR --atom NAME=SPEC ... conformance: every object of the checked classes through the reference and through each candidate; per check and class (or texture format): equal / differ / refused / ... - prepare DIR --base STORE census and link.scripts of the bundles into a base store (the tasks of + prepare DIR --base DEPOT census and link.scripts of the bundles into a base depot (the tasks of `m3` are described from it) - m3 DIR --base STORE --store S cost of a stage per bundle: wall, user + system CPU and peak resident + m3 DIR --base DEPOT --depot D cost of a stage per bundle: wall, user + system CPU and peak resident memory per task, in a worker pool (per-worker peak) or with --fresh in a new process per bundle (imports measured apart) imports [--module M ...] import cost of modules, each set in new processes @@ -42,7 +42,7 @@ A candidate view is the reader candidate's result; the optional methods census(), text_bytes(obj), font_bytes(obj), sprite_image(obj) and the attribute typetree_form serve the checks that have no atom of their own. A candidate -raising NotImplementedError has no such facet ("absent"); `nnnotes.pipeline.atoms.Unsupported` is a refusal. +raising NotImplementedError has no such facet ("absent"); `nnnotes.unity.atoms.Unsupported` is a refusal. Statuses: equal, differ, refused (the candidate raised where the reference gave a result), refused.both, extra (a result where the reference refused), absent, reference-error. Output: one JSON document (-o); m1 and m3 also write @@ -133,7 +133,7 @@ def check_clean_env() -> None: def load_impl(spec: str): """The Impl named by module:attribute (an Impl, or a callable returning one).""" - from nnnotes.pipeline.atoms import Impl + from nnnotes.unity.atoms import Impl module, sep, attr = spec.partition(":") if not sep or not module or not attr: @@ -146,7 +146,7 @@ def load_impl(spec: str): def parse_atoms(items) -> list[tuple[str, str]]: - from nnnotes.pipeline.atoms import ATOMS + from nnnotes.unity.atoms import ATOMS out = [] for it in items or (): @@ -449,7 +449,7 @@ def cmd_sample(a) -> dict: # ---------------------------------------------------------------- m1 def m1_init(cfg: dict) -> None: - from nnnotes.pipeline.atoms import resolve + from nnnotes.unity.atoms import resolve G.clear() G.update(cfg) @@ -477,7 +477,7 @@ def _key(info) -> tuple: def _call(fn, *args): """(result, None) or (None, refusal) where refusal is the Unsupported code or the error's message.""" - from nnnotes.pipeline.atoms import Unsupported + from nnnotes.unity.atoms import Unsupported try: return fn(*args), None @@ -700,7 +700,7 @@ def hdr_reference(inp): import numpy as np from PIL import Image - from nnnotes.pipeline.atoms.texture import ASTC_BLOCKS + from nnnotes.unity.atoms.texture import ASTC_BLOCKS bx, by = ASTC_BLOCKS[int(inp.format)] w, h = int(inp.width), int(inp.height) @@ -723,7 +723,7 @@ def _hdr_library_image(inp): def check_textures(T: Tally, ref, cv, infos, cmap, checks) -> None: - from nnnotes.pipeline.atoms.texture import format_name, is_hdr_astc + from nnnotes.unity.atoms.texture import format_name, is_hdr_astc ref_dec, ref_png = G["ref"]["texture.decode"], G["ref"]["png.encode"] decs, pngs = G["cand"].get("texture.decode", []), G["cand"].get("png.encode", []) @@ -1036,12 +1036,12 @@ def versions() -> dict: return out -# ---------------------------------------------------------------- prepare (base store) +# ---------------------------------------------------------------- prepare (base depot) def _identify(job): - from nnnotes.pipeline.store import Store + from nnnotes.pipeline.depot import Depot - root, store, name = job - s, size = Store(store).identify(Path(root) / name) + root, depot, name = job + s, size = Depot(depot).identify(Path(root) / name) return name, s, size @@ -1049,20 +1049,20 @@ def _census_task(task): from nnnotes.pipeline import census from nnnotes.pipeline.stages import execute - ex = execute(task, G["store"], {"unity.census": census.CensusStage()}) + ex = execute(task, G["depot"], {"unity.census": census.CensusStage()}) return task.subject, task.key, ex.status def cmd_prepare(a) -> dict: from nnnotes.pipeline import census, contract, link from nnnotes.pipeline.contract import Input + from nnnotes.pipeline.depot import Depot from nnnotes.pipeline.stages import Env, describe, execute - from nnnotes.pipeline.store import Store check_clean_env() root, base = Path(a.dir).resolve(), Path(a.base) items = bundle_list(root, a.bundles) - store = Store(base) + depot = Depot(base) t0 = time.perf_counter() ids = pool_map( _identify, [(str(root), str(base), n) for n, _ in sorted(items, key=lambda x: -x[1])], a.workers, maxtasks=None @@ -1074,14 +1074,14 @@ def cmd_prepare(a) -> dict: subject = stable[names[n]] inputs[subject] = Input("bundle", s, size, names[n], ({"kind": "file", "path": str(root / n)},)) subj_name[subject] = n - env = Env(store, {"bundles": inputs}) + env = Env(depot, {"bundles": inputs}) tasks = [describe(census.CensusStage(), s, None, env) for s in sorted(inputs, key=lambda s: -inputs[s].size)] - G["store"] = store + G["depot"] = depot res = pool_map(_census_task, tasks, a.workers) for t in tasks: env.done(t.id, t.key) st = describe(link.ScriptsStage(), "all", None, env) - execute(st, store, {"link.scripts": link.ScriptsStage()}) + execute(st, depot, {"link.scripts": link.ScriptsStage()}) doc = { "dir": str(root), "bundles": { @@ -1101,8 +1101,8 @@ def cmd_prepare(a) -> dict: # ---------------------------------------------------------------- m3 (stage cost) def _prepared_env(base: Path): from nnnotes.pipeline.contract import Input + from nnnotes.pipeline.depot import Depot from nnnotes.pipeline.stages import Env - from nnnotes.pipeline.store import Store prep = json.loads((base / PREPARED).read_text(encoding="utf-8")) root = Path(prep["dir"]) @@ -1116,16 +1116,16 @@ def _prepared_env(base: Path): ) for s, b in prep["bundles"].items() } - env = Env(Store(base), {"bundles": inputs}) + env = Env(Depot(base), {"bundles": inputs}) for s, k in prep["census"].items(): env.done(f"unity.census:{s}", k) env.done("link.scripts:all", prep["scripts"]) return prep, env -def _new_store(base: Path, path: Path): - """A run store that knows the base store's input identities (so inputs are not hashed again).""" - from nnnotes.pipeline.store import Store +def _new_depot(base: Path, path: Path): + """A run depot that knows the base depot's input identities (so inputs are not hashed again).""" + from nnnotes.pipeline.depot import Depot path.mkdir(parents=True, exist_ok=True) src = base / "inputs" @@ -1134,7 +1134,7 @@ def _new_store(base: Path, path: Path): for f in src.iterdir(): if f.is_file() and not (path / "inputs" / f.name).exists(): shutil.copyfile(f, path / "inputs" / f.name) - return Store(path) + return Depot(path) def _stage(name: str): @@ -1144,7 +1144,7 @@ def _stage(name: str): return {"unity.export": ExportStage, "unity.census": census.CensusStage}[name]() -def _run_task(task, store, stage_name: str) -> dict: +def _run_task(task, depot, stage_name: str) -> dict: from nnnotes.pipeline.stages import execute hwm = reset_hwm() @@ -1152,7 +1152,7 @@ def _run_task(task, store, stage_name: str) -> dict: t0 = time.perf_counter() rec = {"subject": task.subject, "key": task.key} try: - ex = execute(task, store, {stage_name: _stage(stage_name)}, force=True) + ex = execute(task, depot, {stage_name: _stage(stage_name)}, force=True) rec.update(status=ex.status, result=ex.result_status) except Exception as e: # noqa: BLE001 rec.update(status="error", error=message(e), trace=traceback.format_exc()[-1200:]) @@ -1169,11 +1169,11 @@ def _run_task(task, store, stage_name: str) -> dict: def _m3_worker(task) -> dict: - return _run_task(task, G["store"], G["stage"]) + return _run_task(task, G["depot"], G["stage"]) def _apply_atoms(atoms) -> None: - from nnnotes.pipeline.atoms import env_name, resolve + from nnnotes.unity.atoms import env_name, resolve for name, spec in atoms: os.environ[env_name(name)] = spec @@ -1184,7 +1184,7 @@ def _apply_atoms(atoms) -> None: def _import_stage_modules() -> dict: """Import what a unity.export task needs, timed (wall, CPU).""" t0, c0 = time.perf_counter(), time.process_time() - for m in ("numpy", "PIL.Image", "nnnotes.pipeline.objexport", "nnnotes.unity.export"): + for m in ("numpy", "PIL.Image", "nnnotes.pipeline.objexport", "nnnotes.unity.texels"): importlib.import_module(m) return {"wall": round(time.perf_counter() - t0, 4), "cpu": round(time.process_time() - c0, 4)} @@ -1229,19 +1229,19 @@ def summarize_runs(recs: list) -> dict: def cmd_m3(a) -> dict: check_clean_env() - base, out_store = Path(a.base), Path(a.store) + base, out_depot = Path(a.base), Path(a.depot) atoms = parse_atoms(a.atom) params = parse_params(a.param) or None prep, env = _prepared_env(base) subjects = _subjects(prep, a.bundles) if a.fresh: - return m3_fresh(a, base, out_store, atoms, params, prep, subjects) + return m3_fresh(a, base, out_depot, atoms, params, prep, subjects) imp = _import_stage_modules() _apply_atoms(atoms) tasks = _describe_all(a.stage, subjects, params, env) tasks.sort(key=lambda t: -t.inputs[0].size) - store = _new_store(base, out_store) - G.update(store=store, stage=a.stage) + depot = _new_depot(base, out_depot) + G.update(depot=depot, stage=a.stage) log("m3", a.stage, len(tasks), "tasks,", a.workers, "workers, atoms", dict(atoms), "params", params) jsonl = Path(a.out).with_suffix(".jsonl") if a.out else None fo = open(jsonl, "w", encoding="utf-8", newline="\n") if jsonl else None @@ -1266,7 +1266,7 @@ def take(r): "maxtasks": a.maxtasks, "atoms": dict(atoms), "params": params, - "store": str(out_store.resolve()), + "depot": str(out_depot.resolve()), "taskAtoms": tasks[0].atoms if tasks else None, "wall": round(time.perf_counter() - t0, 2), "childrenCpu": round((c1.children_user - c0.children_user) + (c1.children_system - c0.children_system), 2), @@ -1281,18 +1281,18 @@ def take(r): return doc -def m3_fresh(a, base, out_store, atoms, params, prep, subjects) -> dict: +def m3_fresh(a, base, out_depot, atoms, params, prep, subjects) -> dict: """One new process per bundle: its wall, user + system CPU and peak RSS (wait4), and inside it the import time and the task's own cost.""" - _new_store(base, out_store) + _new_depot(base, out_depot) cmd = [ sys.executable, str(Path(__file__).resolve()), "m3-one", "--base", str(base), - "--store", - str(out_store), + "--depot", + str(out_depot), "--stage", a.stage, ] @@ -1306,7 +1306,7 @@ def m3_fresh(a, base, out_store, atoms, params, prep, subjects) -> dict: queue = sorted(subjects, key=lambda s: -sizes[s]) running: dict[int, tuple] = {} recs = [] - tmp = out_store / ".atom_eval" + tmp = out_depot / ".atom_eval" tmp.mkdir(exist_ok=True) jsonl = Path(a.out).with_suffix(".jsonl") if a.out else None fo = open(jsonl, "w", encoding="utf-8", newline="\n") if jsonl else None @@ -1362,7 +1362,7 @@ def m3_fresh(a, base, out_store, atoms, params, prep, subjects) -> dict: "workers": a.workers, "atoms": dict(atoms), "params": params, - "store": str(out_store.resolve()), + "depot": str(out_depot.resolve()), "wall": round(time.perf_counter() - t0, 2), "tasks": len(recs), "ok": len(ok), @@ -1400,16 +1400,16 @@ def cmd_m3_one(a) -> None: prep, env = _prepared_env(base) (task,) = _describe_all(a.stage, [a.subject], parse_params(a.param) or None, env) describe_cost = {"wall": round(time.perf_counter() - t0, 4), "cpu": round(time.process_time() - cd, 4)} - from nnnotes.pipeline.store import Store + from nnnotes.pipeline.depot import Depot - rec = _run_task(task, Store(a.store), a.stage) + rec = _run_task(task, Depot(a.depot), a.stage) rec.update(imports=imp, describe=describe_cost, processCpuInside=round(time.process_time() - c0, 4)) print(json.dumps(rec, ensure_ascii=False)) # ---------------------------------------------------------------- imports def cmd_imports(a) -> dict: - sets = [m.split(",") for m in (a.module or ["numpy,PIL.Image,nnnotes.pipeline.objexport,nnnotes.unity.export"])] + sets = [m.split(",") for m in (a.module or ["numpy,PIL.Image,nnnotes.pipeline.objexport,nnnotes.unity.texels"])] code = ( "import resource, sys, time, json\n" "t = time.perf_counter(); c = time.process_time()\n" @@ -1525,10 +1525,10 @@ def _compare_job(job) -> dict: def cmd_compare(a) -> dict: - from nnnotes.pipeline.store import Store + from nnnotes.pipeline.depot import Depot ra, rb = (json.loads(Path(p).read_text(encoding="utf-8")) for p in (a.run_a, a.run_b)) - G.update(sa=Store(ra["store"]), sb=Store(rb["store"])) + G.update(sa=Depot(ra["depot"]), sb=Depot(rb["depot"])) ta, tb = ra["taskKeys"], rb["taskKeys"] jobs = [(s, ta.get(s), tb.get(s)) for s in sorted(ta.keys() | tb.keys())] total = {"counts": {}, "stats": {}, "examples": {}} @@ -1536,8 +1536,8 @@ def cmd_compare(a) -> dict: merge(total, r) doc = { "command": "compare", - "a": {k: ra.get(k) for k in ("atoms", "params", "store", "workers", "mode")}, - "b": {k: rb.get(k) for k in ("atoms", "params", "store", "workers", "mode")}, + "a": {k: ra.get(k) for k in ("atoms", "params", "depot", "workers", "mode")}, + "b": {k: rb.get(k) for k in ("atoms", "params", "depot", "workers", "mode")}, "subjects": len(jobs), **total, } @@ -1583,7 +1583,7 @@ def common(p, bundles=True): else: p.add_argument("--subject", required=True) p.add_argument("--base", required=True) - p.add_argument("--store", required=True) + p.add_argument("--depot", required=True) p.add_argument("--stage", default="unity.export", choices=("unity.export", "unity.census")) p.add_argument("--atom", action="append") p.add_argument("--param", action="append", help="stage parameter path=value, e.g. png.level=1") diff --git a/scripts/atom_eval_pillow.py b/scripts/atom_eval_pillow.py index ec5d9f4..b5741b4 100644 --- a/scripts/atom_eval_pillow.py +++ b/scripts/atom_eval_pillow.py @@ -1,6 +1,6 @@ """Candidate `png.encode` implementations on Pillow, for scripts/atom_eval.py only. -The reference (`nnnotes.pipeline.atoms.png.encode`) writes at the level a task's `png.level` parameter gives (6 by +The reference (`nnnotes.unity.atoms.png.encode`) writes at the level a task's `png.level` parameter gives (6 by default). These fix the setting instead, so several can be measured side by side in one `atom_eval.py m1` run: level1, level3, level6, level9 Pillow at that zlib level @@ -15,8 +15,8 @@ from functools import cache from importlib import metadata -from nnnotes.pipeline.atoms import Impl -from nnnotes.pipeline.atoms import png as png_atom +from nnnotes.unity.atoms import Impl +from nnnotes.unity.atoms import png as png_atom def _pillow() -> str: diff --git a/scripts/atom_eval_unityrs.py b/scripts/atom_eval_unityrs.py index d403c35..e544f1a 100644 --- a/scripts/atom_eval_unityrs.py +++ b/scripts/atom_eval_unityrs.py @@ -27,13 +27,13 @@ from functools import cache from importlib import metadata -from nnnotes.pipeline.atoms import Impl, Unsupported -from nnnotes.pipeline.atoms import png as png_atom -from nnnotes.pipeline.atoms import reader as reader_atom -from nnnotes.pipeline.atoms import texture as texture_atom -from nnnotes.pipeline.atoms.mesh import MeshInput -from nnnotes.pipeline.atoms.reader import ObjectInfo, UnityBundle -from nnnotes.pipeline.atoms.texture import TextureInput +from nnnotes.unity.atoms import Impl, Unsupported +from nnnotes.unity.atoms import png as png_atom +from nnnotes.unity.atoms import reader as reader_atom +from nnnotes.unity.atoms import texture as texture_atom +from nnnotes.unity.atoms.mesh import MeshInput +from nnnotes.unity.atoms.reader import ObjectInfo, UnityBundle +from nnnotes.unity.atoms.texture import TextureInput FALLBACKS = {"png": 0} _KEEP = "unity_rs.image" @@ -390,7 +390,7 @@ def png_encode_fast() -> Impl: def _mesh(inp: MeshInput): import numpy as np - from nnnotes.pipeline.atoms.mesh import Channel, MeshArrays, Primitive + from nnnotes.unity.atoms.mesh import Channel, MeshArrays, Primitive src = getattr(inp, "source", None) if src is None: @@ -429,6 +429,6 @@ def _mesh(inp: MeshInput): @cache def mesh_arrays() -> Impl: - from nnnotes.pipeline.atoms import mesh as mesh_atom + from nnnotes.unity.atoms import mesh as mesh_atom return Impl(_id(_rs(), "obj"), _mesh, mesh_atom.cost) diff --git a/scripts/check_product_schemas.py b/scripts/check_product_schemas.py new file mode 100644 index 0000000..0558f6b --- /dev/null +++ b/scripts/check_product_schemas.py @@ -0,0 +1,76 @@ +"""Compare the shared product contracts with an ournotes-player checkout or extracted package.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +NAMES = ( + "product", + "episode", + "model-json", + "prefab", + "live", + "live-scene", + "live-notes", + "live-noteskin", + "live-noteeffect", + "live-music", + "live-sounds", + "story", + "story-scene", + "story-ui", + "story-asset", + "story-fonts", + "story-host", + "story-simple-ui", + "ui-pack", + "ui-library", + "room", + "spot", + "story-video", + "story-sound", + "crilips", +) + + +def contract(value): + if isinstance(value, dict): + out = {} + for key, item in value.items(): + if key in {"$id", "title", "description"}: + continue + if key in {"properties", "patternProperties", "$defs", "dependentSchemas"}: + out[key] = {name: contract(schema) for name, schema in item.items()} + elif key in {"const", "enum"}: + out[key] = item + else: + out[key] = contract(item) + return out + if isinstance(value, list): + return [contract(item) for item in value] + return value + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("player", type=Path, help="ournotes-player root containing schema/") + args = parser.parse_args() + source = Path(__file__).resolve().parents[1] / "src" / "nnnotes" / "schemas" + errors = [] + for name in NAMES: + paths = [source / f"{name}.schema.json", args.player / "schema" / f"{name}.schema.json"] + try: + left, right = [contract(json.loads(path.read_text(encoding="utf-8"))) for path in paths] + if left != right: + errors.append(f"{name}: schema constraints differ") + except (OSError, ValueError) as error: + errors.append(f"{name}: {error}") + if errors: + parser.exit(1, "\n".join(errors) + "\n") + print(f"{len(NAMES)} shared product schemas match") + + +if __name__ == "__main__": + main() diff --git a/scripts/gen_font_settings.py b/scripts/gen_font_settings.py index 428ef86..0db28d3 100644 --- a/scripts/gen_font_settings.py +++ b/scripts/gen_font_settings.py @@ -1,51 +1,45 @@ -"""Regenerate the font settings table the open-font exports read (maintainer tool; not installed, not needed to use -nnnotes). - -With `--fonts open` (the default) the story, start canvas and talk window exports give each localized game font -asset an open font asset with that asset's point size, padding, render mode, style settings, line metrics, fallback -chain and text materials. nnnotes ships these values in `src/nnnotes/unity/fontsettings.json` (fontsettings.FORMAT), so -those exports read no game font asset; this script reads them from the game's font assets and writes the file. - -Supporting a new game version ------------------------------ -When the game changes its font assets or their materials (an open-font export stops with "... not in the font -settings", or the values differ from the game's), regenerate the table with a configured catalog and APK of that -version (`nnnotes config check`): - - python scripts/gen_font_settings.py [--region REGION] [--game-version VERSION] [-o PATH] - -It reads LocalizeManager's font names of every language and their material types from the player data, each of -those font assets with the assets of its fallback chain, their default materials and every localized text material -(" - ") the catalog has, and writes the table (`--game-version` default: the APK's versionName). The -`fonts` extra must be installed. +"""Generate a font settings table from the inputs of a saved text-product task. + +Usage: python scripts/gen_font_settings.py TASK.json --depot DEPOT [-o OUTPUT] + +The task's object table, font-bundle identities and APK manifest are read through the depot. The table contains +the font assets LocalizeManager names, their fallback chains, materials and object ids. Text products with these +inputs include story.fonts and live.ui. The fonts extra must be installed. """ from __future__ import annotations import argparse import sys -import tempfile from pathlib import Path -from nnnotes.site.web import open_data +from nnnotes.content import storyui +from nnnotes.content.prefabs import mono_json +from nnnotes.pipeline import contract, textfonts +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import open_depot from nnnotes.source.config import Config, use -from nnnotes.unity import fontsettings, tmpfont -from nnnotes.unity.export import Exporter +from nnnotes.unity import fontsettings, textstyle + + +def generate(task, depot) -> dict: + """Font settings from the complete stored inputs of a text product task.""" + objects = Objects(contract.loads(depot.input_bytes(task.input("objects"))), depot) + localize = storyui.script_root(objects, "LocalizeManager") + language = textstyle.language_fonts_json(mono_json(objects, localize)) + return textfonts.font_settings(task, objects, language).doc def main(argv=None) -> None: - p = argparse.ArgumentParser( - description="regenerate src/nnnotes/unity/fontsettings.json from the game's font assets" - ) - p.add_argument("--region", help="region of the catalog (default: [catalog] region)") - p.add_argument("--game-version", help="game version to record (default: the APK manifest's versionName)") - p.add_argument("-o", "--out", type=Path, default=fontsettings.FILE, help="output file") - args = p.parse_args(argv) - cfg = use(Config.load()) - cat, _, player = open_data(cfg, args.region) - with tempfile.TemporaryDirectory(prefix="fontsettings-") as work: - ex = Exporter(cat, Path(work), player=player, textures="deferred") - doc = tmpfont.build_font_settings(ex, player, args.game_version or player.game_version) + parser = argparse.ArgumentParser(description="generate font settings from a saved text-product task") + parser.add_argument("task", type=Path, help="nnnotes.task/1 JSON with font inputs") + parser.add_argument("--depot", type=Path, help="depot holding the task inputs ([paths] depot)") + parser.add_argument("-o", "--out", type=Path, default=fontsettings.FILE, help="output table") + args = parser.parse_args(argv) + overrides = {("paths", "depot"): str(args.depot.resolve())} if args.depot else {} + cfg = use(Config.load(overrides=overrides)) + task = contract.Task.from_json(contract.loads(args.task.read_bytes())) + doc = generate(task, open_depot(cfg)) fontsettings.write(doc, args.out) print( f"{args.out}: {len(doc['fonts'])} font assets, {len(doc['materials'])} materials, " diff --git a/scripts/gen_typetrees.py b/scripts/gen_typetrees.py index 50e626e..75f3ad3 100644 --- a/scripts/gen_typetrees.py +++ b/scripts/gen_typetrees.py @@ -2,18 +2,22 @@ nnnotes). nnnotes reads a few MonoBehaviours of the APK's boot data (`assets/bin/Data/data.unity3d`): the URP pipeline -assets, renderer data, renderer features and post-process data, DOTween and TextMeshPro settings, and the sound -and app-config defaults. IL2CPP player builds strip script typetrees from that data, so nnnotes ships the -typetrees of exactly these classes (CLASSES below) in `src/nnnotes/unity/typetrees/.json`, one file per -Unity version. Each entry holds the typetree nodes as [level, type, name, meta flag] rows and the type hash the -serialized file records for the class; the file also records the game version it was generated from. +assets, renderer data, renderer features and post-process data, DOTween and TextMeshPro settings, the sound and +app-config defaults and the localization manager (the `apk/data.unity3d` tasks of `unity.export`, and PlayerData). +IL2CPP player builds strip script typetrees from that data, so nnnotes ships the typetrees of exactly these classes +(CLASSES below) in `src/nnnotes/unity/typetrees/.json`, one file per Unity version (read by +nnnotes.unity.scripttypes). +Each entry holds the typetree nodes as [level, type, name, meta flag] rows and the type hash the serialized file +records for the class; the file also records the game version it was generated from. The rows have the node layout Unity itself serializes for a MonoBehaviour (as in the typetrees of asset bundles built with them): root type `MonoBehaviour`; a `string` is `string / Array / int size / char data`; built-in structs carry their native names (`Vector3f`, `ColorRGBA`, `AABB`, ...), and arrays of them, of strings and of PPtrs are `vector`; the align flag (0x4000, align the stream to 4 bytes after the node) sits where Unity puts it. unity_rows turns the rows of TypeTreeGeneratorAPI's default (AssetsTools) backend into that layout. The other meta -flag bits are editor-only and left 0. +flag bits are editor-only and left 0. A field after which the serialized bytes are aligned, although the generated +tree does not say so, is listed in ALIGNED_FIELDS; generate() sets its align flag and the read check of its class +verifies it. Supporting a new game version ----------------------------- @@ -34,7 +38,7 @@ not read back stops the script without writing anything. 3. When a new game version keeps the Unity version, the file is replaced; a new Unity version adds a file. Keep the older files while the game versions they describe are still in use. -4. When code starts reading another MonoBehaviour class with `PlayerData.mono`, add the class to CLASSES and +4. When code starts reading another MonoBehaviour class of these files, add the class to CLASSES and regenerate. """ @@ -47,13 +51,15 @@ from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator -from nnnotes.unity.player import TYPETREE_FORMAT, PlayerData, dump_typetrees, typetree_key, typetree_node +from nnnotes.unity.player import PlayerData +from nnnotes.unity.scripttypes import TYPETREE_FORMAT, dump_typetrees, typetree_key, typetree_node # (assembly, namespace, class) as the MonoScript objects name them CLASSES = ( ("Adv", "AdvSystem.Rendering", "AdvFieldRendererFeature"), ("App.Runtime", "App.Config", "AppConfigDefaultData"), ("DOTween.dll", "DG.Tweening.Core", "DOTweenSettings"), + ("Fwk", "Fwk.Localization", "LocalizeManager"), ("Fwk", "Fwk.Rendering", "ScreenCaptureRendererFeature"), ("Fwk", "Fwk.Sound", "SoundVolumeSettings"), ("Fwk", "Fwk.UI.Rendering", "UIRendererFeature"), @@ -74,6 +80,8 @@ ALIGN = 0x4000 # TransferMetaFlags.kAlignBytesFlag # the MonoBehaviour header: field -> meta flag HEADER = {"m_GameObject": 0, "m_Enabled": ALIGN, "m_Script": 0, "m_Name": 0} +# class -> its root fields after which the serialized bytes are aligned (the flag the generated tree leaves out) +ALIGNED_FIELDS = {"LocalizeManager": ("_dontDestroyOnload",)} # built-in structs the generator names after their C# type -> the native type name Unity serializes NATIVE_TYPES = { "Bounds": "AABB", @@ -194,6 +202,17 @@ def unity_rows(rows: list) -> list: return _rows(root) +def _aligned(rows: list, names: tuple) -> list: + """The rows with the align flag set on the root fields `names` (ALIGNED_FIELDS); ValueError for a missing one.""" + roots = {r[2] for r in rows if r[0] == 1} + if not set(names) <= roots: + raise ValueError(f"no root field {sorted(set(names) - roots)}") + for r in rows: + if r[0] == 1 and r[2] in names: + r[3] |= ALIGN + return rows + + def objects_by_class(player: PlayerData) -> dict[tuple[str, str, str], list]: out: dict[tuple[str, str, str], list] = {} for o in player._by_type.get("MonoBehaviour", []): @@ -220,6 +239,7 @@ def generate(player: PlayerData, gen: TypeTreeGenerator, game_version: str) -> d for n in gen.get_nodes(asm if asm.endswith(".dll") else f"{asm}.dll", name) ] ) + rows = _aligned(rows, ALIGNED_FIELDS.get(cls, ())) except ValueError as e: raise SystemExit(f"{name} ({asm}): {e}") from e node = typetree_node(rows) diff --git a/src/nnnotes/__init__.py b/src/nnnotes/__init__.py index 7d6ba71..c111a90 100644 --- a/src/nnnotes/__init__.py +++ b/src/nnnotes/__init__.py @@ -19,7 +19,7 @@ import importlib from typing import TYPE_CHECKING -__version__ = "0.2.1" +__version__ = "0.3.0" _LAZY = { "Catalog": "nnnotes.source.catalog", diff --git a/src/nnnotes/cli/__init__.py b/src/nnnotes/cli/__init__.py index 0020406..57f058d 100644 --- a/src/nnnotes/cli/__init__.py +++ b/src/nnnotes/cli/__init__.py @@ -13,7 +13,7 @@ nnnotes master version|download|decode nnnotes export assets|episode|story|live|live2d|spot|room|shader|audio|ui ... -o OUT nnnotes apk settings -o FILE | hca-key [--write DIR] - nnnotes store plan|run|verify + nnnotes depot plan|run|verify|gc nnnotes voices list|search|summary|get nnnotes site build -o SITE nnnotes music-data -o FILE @@ -25,6 +25,7 @@ from __future__ import annotations import argparse +import contextlib import os import sys @@ -36,10 +37,11 @@ from .. import __version__ from ..source.config import ConfigError from ..source.gameapi import GameApiError -from . import apk, bundle, catalog, config, export, master, musicdata, servers, site, store, voices +from . import apk, bundle, catalog, config, depot, export, master, musicdata, servers, site, voices from .common import GLOBALS, Formatter, fill_globals, load_config -GROUPS = (config, servers, catalog, bundle, master, export, apk, store, voices, site, musicdata) +GROUPS = (config, servers, catalog, bundle, master, export, apk, depot, voices, site, musicdata) +NO_DEPOT = {"config", "servers", "master", "apk"} # the groups whose commands never read the depot def build_parser() -> argparse.ArgumentParser: @@ -64,12 +66,35 @@ def main(argv=None): args = parse_args(argv) try: cfg = load_config(args) if args.reads_config else None - args.func(args, cfg) + with depot_lease(args, cfg) as args.lease: + if args.lease is not None and not getattr(args, "dry_run", False): + from ..pipeline.depot import command_run, open_depot + + name = args.command + "." + args.func.__name__.removeprefix("cmd_") + with command_run(open_depot(cfg), name): + args.func(args, cfg) + else: + args.func(args, cfg) except ConfigError as e: print(f"nnnotes: {e}", file=sys.stderr) sys.exit(2) except GameApiError as e: sys.exit(f"nnnotes: {e}") + except TimeoutError as e: + sys.exit(f"nnnotes: {e}") + + +def depot_lease(args, cfg): + """The lease a command holds on the depot while it runs (core.downloads.lease: the last command to end trims the + downloads to `[depot] downloads`; `args.lease` is its file); none for the commands that never read the depot or + without `[paths] depot`.""" + if cfg is None or args.command in NO_DEPOT or cfg.path("paths", "depot") is None: + return contextlib.nullcontext() + from ..core.downloads import lease + from ..source.session import Session + + session = Session(cfg) + return lease(session.depot_root(), None if getattr(args, "dry_run", False) else session.downloads_budget()) if __name__ == "__main__": diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index 3536ff9..8dc9e0e 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -1,44 +1,51 @@ -"""The asset commands: the default orchestration of the stages (`export assets`), its plan and single tasks (`store -plan`, `store run`). +"""The asset commands: the default orchestration of the stages (`export assets`), its plan and single tasks (`depot +plan`, `depot run`). - nnnotes export assets -o OUT [--layout original[,cas]] [--select group:G|key:PREFIX|bundle:GLOB ...] + nnnotes export assets -o OUT [--layout original[,cas][,product]] [--select group:G|key:PREFIX|bundle:GLOB ...] [--views all|none|a,b] [--workers N] [--memory GiB] [--fetch-workers N] [--stored-catalog L|SHA] [--png-level N] [--flac-level N] [--only-class C,...] [--link auto|clone|hard|copy] [--strict] [--dry-run] [--explain] - nnnotes store plan [the selection and parameters of export assets] [-o OUT] [--since RUN] [--check] [--census] + nnnotes depot plan [the selection and parameters of export assets] [-o OUT] [--since RUN] [--check] [--census] [--emit-tasks DIR] [--why TASK] - nnnotes store run TASK.json|DIR [...] [--fetch] [--force] + nnnotes depot run TASK.json|DIR [...] [--fetch] [--force] The pipeline is the registry STAGE_SOURCES in order: each entry names the module and attribute of a stage (a Stage class) or of a family of stages (a function returning them, `view.`). An entry whose module or attribute is not installed is left out of the pipeline and named by --explain. Stage modules import heavy libraries (UnityPy) only -when a task runs, so `store plan`, and `store run` of stages that do not read bundles, never load them. +when a task runs, so `depot plan`, and `depot run` of stages that do not read bundles, never load them. Facts the stages read while their tasks are described (stages.Env): - catalogs {"main": [Input remote, Input apk]}: the catalog version's files in /catalogs/ + catalogs {"main": [Input remote, Input apk]}: the catalog version's files in /catalogs/ index the catalog index document (nnnotes.catalog-index/1) bundles {stable bundle name: Input "bundle"} of the selected bundles and their dependencies (a bundle whose - bytes are not known yet: a Pending "fetch:") - selected the selected bundles' stable names, sorted (the subjects of the export stages) + bytes are not known yet: a Pending "fetch:"), and of the APK's built-in files + (`apk/data.unity3d`, `apk/Resources/unity default resources`: pipeline.builtins) + selected the selected bundles' stable names, sorted (the subjects of the export stages; none when only products + are asked for: the run exports what they reach) raw {stable raw file name: Input "raw"} of the selected raw files (only with a stage that reads them; export fetches them while the stages before run, and reading the fact waits for that) boot the Input of the game's boot data (with a CRI stage: cristages.boot_input; None without an APK) master the decoded master data directory, or None views the names of the selected views, sorted + products {product stage: subjects} (with a product stage in the pipeline: the `product` layout makes each + stage's products of the keys the selected bundles hold, held_keys; `export live2d` asks for one) + roots the root objects of those products (pipeline.assemble.request) + apk {path in the APK: Input "apk:"} of the APK files the product stages read (their `APK`) Selection (--select, repeatable; the union; none: everything): `group:G` the bundles of an Addressables group (the stable name up to `_assets_`, else up to its first `_`), `key:PREFIX` the bundles and raw files the catalog keys under PREFIX load (their dependency closure), `bundle:GLOB` bundles and raw files whose stable name matches. The census also covers the bundles the selected ones depend on (the dependencies of the keys they hold: a key's first -dependency is its own bundle), so script and atlas references resolve. +dependency is its own bundle), so script and atlas references resolve. With `[paths] apk` the census always covers +the APK's built-in files; everything and `bundle:GLOB` (`bundle:apk/*`) select them. Task cost estimates (scheduling only: never part of a key) are each stage's estimate scaled per stage by the -costs measured in earlier runs (/costs/calibration.json, updated after every export). +costs measured in earlier runs (/costs/calibration.json, updated after every export). Exit codes: export assets 0 complete (unsupported objects allowed), 1 item or task failures (reports written), 2 -usage or settings, 3 aborted (results written so far stay valid); store run 0, 1 (a task failed or a result is -partial), 2, 4 an incompatible task (nothing of the batch runs); store plan --check 1 when anything would run or +usage or settings, 3 aborted (results written so far stay valid); depot run 0, 1 (a task failed or a result is +partial), 2, 4 an incompatible task (nothing of the batch runs); depot plan --check 1 when anything would run or change. """ @@ -67,7 +74,8 @@ from .common import nonnegative, print_json, usage GIB = 1 << 30 -LAYOUT_NAMES = ("original", "cas") +LAYOUT_NAMES = ("original", "cas", "product") +OBJECT_LAYOUTS = ("original", "cas") # the layouts of the objects: they ask for every object of the selection FETCH_WORKERS = 8 RECYCLE_TASKS = 500 RECYCLE_RSS = 3 * GIB # a worker whose resident memory after a task is above this is replaced ... @@ -86,12 +94,14 @@ class StageSource: """Where a stage (or, for a name ending with '.', a family of stages) comes from. `output`: its artifacts are placed in the layouts; `root`: the layout root of its artifacts without a container path (format fields - {stage} and {subject}; a stage's LAYOUT_ROOT attribute wins).""" + {stage} and {subject}; a stage's LAYOUT_ROOT attribute wins); `product`: a product stage (pipeline.assemble), + in the pipeline only when products are asked for, its results placed by the product layouts only.""" name: str target: str output: bool = False root: str | None = None + product: bool = False def matches(self, stage: str) -> bool: return stage.startswith(self.name) if self.name.endswith(".") else stage == self.name @@ -102,13 +112,43 @@ def matches(self, stage: str) -> bool: StageSource("unity.census", "nnnotes.pipeline.census:CensusStage"), StageSource("link.scripts", "nnnotes.pipeline.link:ScriptsStage"), StageSource("link.addresses", "nnnotes.pipeline.link:AddressesStage"), - StageSource("unity.export", "nnnotes.pipeline.objexport:ExportStage", output=True, root="_bundles/{subject}"), + StageSource("unity.export", "nnnotes.pipeline.objexport:ExportStage", output=True), StageSource("sprite.crop", "nnnotes.pipeline.objexport:SpriteCropStage", output=True), StageSource("cri.audio", "nnnotes.pipeline.cristages:AudioStage", output=True), StageSource("cri.movie", "nnnotes.pipeline.cristages:MovieStage", output=True), - StageSource("live2d.model", "nnnotes.pipeline.modelstages:Live2DStage", output=True), - StageSource("spine.skeleton", "nnnotes.pipeline.modelstages:SpineStage", output=True), - StageSource("link.artifacts", "nnnotes.pipeline.link:ArtifactsStage"), + StageSource("export.episode", "nnnotes.pipeline.exportfiles:EpisodeStage", output=True, product=True), + StageSource("export.audio", "nnnotes.pipeline.exportfiles:AudioFilesStage", output=True, product=True), + StageSource("export.shader", "nnnotes.pipeline.exportfiles:ShaderFilesStage", output=True), + StageSource("live2d.model", "nnnotes.pipeline.live2d:ModelStage", output=True, product=True), + StageSource("live2d.cubism", "nnnotes.pipeline.live2d:CubismStage", output=True, product=True), + StageSource("room.gltf", "nnnotes.pipeline.spot:RoomStage", output=True, product=True), + StageSource("spot.files", "nnnotes.pipeline.spot:SpotStage", output=True, product=True), + StageSource("live.music", "nnnotes.pipeline.live:MusicStage", output=True, product=True), + StageSource("live.sounds", "nnnotes.pipeline.live:SoundsStage", output=True, product=True), + StageSource("live.notes", "nnnotes.pipeline.live:NotesStage", output=True, product=True), + StageSource("live.noteskin", "nnnotes.pipeline.live:NoteSkinStage", output=True, product=True), + StageSource("live.noteeffect", "nnnotes.pipeline.live:NoteEffectStage", output=True, product=True), + StageSource("live.scene", "nnnotes.pipeline.live:SceneStage", output=True, product=True), + StageSource("live", "nnnotes.pipeline.live:LiveStage", output=True, product=True), + StageSource("live.ui", "nnnotes.pipeline.live:UiStage", output=True, product=True), + StageSource("story.sound", "nnnotes.pipeline.story:SoundStage", output=True, product=True), + StageSource("story.crilips", "nnnotes.pipeline.story:CriLipsStage", output=True, product=True), + StageSource("story.scene", "nnnotes.pipeline.story:SceneStage", output=True, product=True), + StageSource("story.asset", "nnnotes.pipeline.story:AssetStage", output=True, product=True), + StageSource("story.video", "nnnotes.pipeline.story:VideoStage", output=True, product=True), + StageSource("story.chat", "nnnotes.pipeline.story:ChatStage", output=True, product=True), + StageSource("story.ui", "nnnotes.pipeline.story:UiStage", output=True, product=True), + StageSource("story.fonts", "nnnotes.pipeline.story:FontsStage", output=True, product=True), + StageSource("story.simpleui", "nnnotes.pipeline.story:SimpleUiStage", output=True, product=True), + StageSource("story.host", "nnnotes.pipeline.storyhost:HostStage", output=True, product=True), + StageSource("story", "nnnotes.pipeline.story:StoryStage", output=True, product=True), + StageSource("ui.pack", "nnnotes.pipeline.ui:UiStage", output=True, product=True), + StageSource("site.asset", "nnnotes.pipeline.siteassets:AssetStage"), + StageSource("site.references", "nnnotes.pipeline.siteproducts:ReferencesStage"), + StageSource("site.manifest", "nnnotes.pipeline.siteproducts:ManifestStage"), + StageSource("site.readset", "nnnotes.pipeline.readsets:ReadSetStage"), + StageSource("site.index", "nnnotes.pipeline.siteindex:IndexStage"), + StageSource("site.selection", "nnnotes.pipeline.siteproducts:SelectionStage"), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), ) @@ -147,7 +187,7 @@ class Installed: def installed(names=None) -> Installed: - """The stages of STAGE_SOURCES (`names`: only the sources of these stage names, for `store run`).""" + """The stages of STAGE_SOURCES (`names`: only the sources of these stage names, for `depot run`).""" stages, of, missing = {}, {}, [] for src in STAGE_SOURCES: if names is not None and not any(src.matches(n) for n in names): @@ -193,14 +233,16 @@ class Selection: raw: list # stable names of the selected raw files -def select(index: dict, selection: list[str]) -> Selection: - """The bundles and raw files a selection names in a catalog index (module documentation).""" +def select(index: dict, selection: list[str], builtin=()) -> Selection: + """The bundles and raw files a selection names in a catalog index, and of the built-in subjects `builtin` + (module documentation).""" from ..pipeline import catalogdb bundles = sorted(b["stable"] for b in index["bundles"]) raws = sorted(r["stable"] for r in index["rawFiles"]) + builtin = sorted(builtin) if selection == ["all"]: - return Selection(bundles, bundles, raws) + return Selection(sorted(bundles + builtin), sorted(bundles + builtin), raws) files = catalogdb.file_refs(index) reach = catalogdb.closures(index) chosen: set = set() @@ -223,8 +265,27 @@ def select(index: dict, selection: list[str]) -> Selection: and files.get(loc["dependencies"][0]) in chosen ): deps |= reach[loc["id"]] + globs = [v for k, _, v in (s.partition(":") for s in selection) if k == "bundle"] + own = [s for s in builtin if any(fnmatch.fnmatchcase(s, g) for g in globs)] pick = lambda s, kind: sorted(n for k, n in s if k == kind) # noqa: E731 - return Selection(pick(chosen, "bundle"), pick(deps, "bundle"), pick(chosen, "raw")) + return Selection(sorted(pick(chosen, "bundle") + own), sorted(pick(deps, "bundle") + builtin), pick(chosen, "raw")) + + +def held_keys(index: dict, bundles) -> list[str]: + """The catalog keys of the asset locations the bundles `bundles` hold (a location's first dependency is the + bundle holding it), sorted.""" + from ..pipeline import catalogdb + + files, want = catalogdb.file_refs(index), {("bundle", b) for b in bundles} + return sorted( + { + loc["primaryKey"] + for loc in index["locations"] + if loc["kind"] not in catalogdb.FILE_KINDS + and loc["dependencies"] + and files.get(loc["dependencies"][0]) in want + } + ) # ---------------------------------------------------------------- settings @@ -257,56 +318,56 @@ def _bundle_key(cfg): class CatalogFetcher: """The fetcher of "catalog" locators ({catalog: version id, location: location id}): the file is fetched through - that catalog version of the store (the settings' CDN, bundle key and APK) into the cache. Crosses to worker - processes as its settings.""" + that catalog version of the depot (the settings' CDN, bundle key and APK) into the depot's downloads/. Crosses to + worker processes as its settings.""" - def __init__(self, store_root_, cache, cfg): - self.root, self.cache, self.cfg = Path(store_root_), Path(cache) if cache else None, cfg + def __init__(self, root, cfg): + self.root, self.cfg = Path(root), cfg + self.downloads = self.root / "downloads" self._catalogs: dict = {} self._lock = threading.Lock() def __getstate__(self): - return {"root": self.root, "cache": self.cache, "cfg": self.cfg} + return {"root": self.root, "cfg": self.cfg} def __setstate__(self, state): - self.__init__(state["root"], state["cache"], state["cfg"]) + self.__init__(state["root"], state["cfg"]) def catalog(self, vid: str): """(Catalog, {location id: location}) of a stored catalog version.""" from ..pipeline import catalogdb + from ..pipeline.depot import open_depot from ..source.catalog import Catalog with self._lock: hit = self._catalogs.get(vid) if hit is None: - if self.cache is None: - raise self.cfg.missing("paths", "cache") - db = catalogdb.CatalogDB(self.root) + db = catalogdb.CatalogDB(self.root, remote=open_depot(self.cfg, root=self.root).remote) v = next((x for x in db.versions() if x["id"] == vid), None) if v is None: - raise FileNotFoundError(f"catalog version {vid[:12]} is not in the store") + raise FileNotFoundError(f"catalog version {vid[:12]} is not in the depot") remote, apk = db.catalog_bytes(v) cfg, region = self.cfg, v.get("region") region = region or cfg.get("catalog", "region") if region: cfg = cfg.for_region(region) - source, client, cache = None, None, self.cache + source, client, downloads = None, None, self.downloads if v.get("source") is not None: from ..source.jp import Client, Source source = Source.from_dict(v["source"]) client = Client(cfg, cfg.region()) - cache = source.cache_dir(cache) + downloads = source.download_dir(downloads) cat = Catalog( remote, - cache, + downloads, cdn=lambda: cfg.cdn(cfg.region()), bundle_key=lambda: _bundle_key(cfg), apk_catalog=apk, source=source, client=client, ) - # Replay imported offsets; the current APK is needed only for uncached local files. + # Replay imported offsets; the current APK is needed only for local files not downloaded yet. cat.apk = cfg.path("paths", "apk") hit = self._catalogs[vid] = (cat, catalogdb.by_id(catalogdb.index(remote, apk))) return hit @@ -327,13 +388,16 @@ def fetch_location(self, vid: str, lid: str) -> Path: return cat.fetch_raw({"internal_id": iid}) def _apk_file(self, internal_id: str, cat) -> Path: - """A raw APK file through its snapshot's cache; only a cache miss needs the matching APK.""" + """A raw APK file through its snapshot's downloads; only a file not read yet needs the matching APK.""" from ..core.cache import write_atomic + from ..core.downloads import touch from ..source.catalog import APK_AA_DIR rel = apk_rel(internal_id) - dst = cat.local_cache_dir() / "raw" / rel - if not (dst.is_file() and dst.stat().st_size > 0): + dst = cat.local_download_dir() / "raw" / rel + if dst.is_file() and dst.stat().st_size > 0: + touch(dst) + else: if cat.apk is None: raise self.cfg.missing("paths", "apk") with ApkSet(cat.apk) as z: @@ -354,7 +418,7 @@ def apk_rel(internal_id: str) -> str: return internal_id[len(LOCAL_PREFIX) :].lstrip("/") if internal_id.startswith(LOCAL_PREFIX) else internal_id -def _worker_init(store, cfg) -> None: +def _worker_init(depot, cfg) -> None: """Every worker process: the settings of the command.""" use(cfg) @@ -366,7 +430,7 @@ def _log(message: str) -> None: # ---------------------------------------------------------------- cost calibration -CALIBRATION = "calibration.json" # /costs/calibration.json +CALIBRATION = "calibration.json" # /costs/calibration.json MIN_SAMPLES = 10 @@ -388,7 +452,7 @@ def estimate(self, subject, env, inputs): def load_calibration(root: Path) -> dict: - """{stage: {"cpu": factor, "peak": factor, "samples": n}} of a store ({} when none was measured).""" + """{stage: {"cpu": factor, "peak": factor, "samples": n}} of a depot ({} when none was measured).""" try: doc = contract.loads((Path(root) / "costs" / CALIBRATION).read_bytes()) except (OSError, ValueError): @@ -442,27 +506,29 @@ def calibrate(log: list[dict], factors: dict, worker_bytes: int) -> dict: def write_calibration(root: Path, factors: dict) -> None: - from ..pipeline.store import write_file + from ..pipeline.depot import write_file write_file(Path(root) / "costs" / CALIBRATION, contract.encode({"stages": factors})) # ---------------------------------------------------------------- the workspace of a command class Workspace: - """What export and plan share: the store, the catalog version, its index, the selection, the pipeline and its + """What export and plan share: the depot, the catalog version, its index, the selection, the pipeline and its parameters, the facts.""" - def __init__(self, args, cfg, log=_log): + def __init__(self, args, cfg, log=_log, layouts=()): from ..pipeline.catalogdb import CatalogDB - from ..pipeline.store import Store + from ..pipeline.depot import open_depot self.args, self.cfg, self.log = args, cfg, log - self.root = Session(cfg).store_root() - self.cache = cfg.path("paths", "cache") + self.layouts = list(layouts) + self.products: dict | None = None # {product stage: subjects} a command asks for (request) + self.product_inputs: dict[str, Input] = {} + self.root = Session(cfg).depot_root() self.apk = cfg.path("paths", "apk") - self.fetcher = CatalogFetcher(self.root, self.cache, cfg) - self.store = Store(self.root, cache=self.cache, fetch=self.fetcher) - self.db = CatalogDB(self.root) + self.fetcher = CatalogFetcher(self.root, cfg) + self.depot = open_depot(cfg, fetch=self.fetcher) + self.db = CatalogDB(self.root, remote=self.depot.remote) try: self.selection = parse_selection(getattr(args, "select", None)) except ValueError as e: @@ -521,24 +587,24 @@ def catalogs_fact(self) -> dict: def index_task(self) -> Task: stage = self.installed.stages["catalog.index"] - return describe(stage, CATALOG_SUBJECT, None, Env(self.store, {"catalogs": self.catalogs_fact()})) + return describe(stage, CATALOG_SUBJECT, None, Env(self.depot, {"catalogs": self.catalogs_fact()})) def index(self) -> dict: - """The catalog index: from the store when catalog.index has run, else computed here.""" + """The catalog index: from the depot when catalog.index has run, else computed here.""" if self._index is None: from ..pipeline import catalogdb t = self.index_task() - doc = self.store.result(t.key) + doc = self.depot.result(t.key) if doc is not None: (rec,) = [a for a in doc["artifacts"] if a["id"] == contract.artifact_id(t.id, "index")] - self._index = self.store.document(rec["content"]["sha256"]) + self._index = self.depot.document(rec["content"]["sha256"]) else: self._index = catalogdb.index(self.remote, self.apk_catalog) return self._index def run_index(self) -> None: - execute(self.index_task(), self.store, self.installed.stages) + execute(self.index_task(), self.depot, self.installed.stages) self._index = None def context(self) -> dict: @@ -607,13 +673,33 @@ def views(self) -> list[str]: raise self.cfg.missing(*self.cfg.master(self.cfg.get("catalog", "region"))) return want + def request(self, products: dict, selection: list[str], inputs: dict[str, Input] | None = None) -> None: + """Ask for the products `products` {product stage: subjects} alone, with the selection `selection` (what + their census needs) instead of the command's: only what the products reach is exported. `inputs` supplies + immutable content inputs from a preceding run as the planning fact `product_inputs`.""" + unknown = sorted(n for n in products if n not in self.installed.stages) + if unknown: + raise ValueError(f"no product stage {', '.join(unknown)} is installed") + self.products, self.selection = {k: sorted(v) for k, v in products.items()}, list(selection) + self.product_inputs = dict(inputs or {}) + + def _wanted(self, name: str) -> bool: + if not self.installed.sources[name].product: + return True + return name in self.products if self.products is not None else "product" in self.layouts + def pipeline(self) -> list[str]: views = set(self.views()) - return [n for n in self.installed.stages if not n.startswith("view.") or n in views] + return [n for n in self.installed.stages if (not n.startswith("view.") or n in views) and self._wanted(n)] def params(self, pipeline: list[str]) -> dict: """{stage: parameters} from the flags; a flag no stage of the pipeline takes is a usage error.""" stages, out = self.installed.stages, {} + for name, flag in (("story", "story_languages"), ("live", "live_languages")): + if name in pipeline and hasattr(self.args, flag): + out.setdefault(name, {})["languages"] = getattr(self.args, flag) or [ + self.cfg.get("catalog", "language") + ] level = getattr(self.args, "png_level", None) if level is not None: if not 0 <= level <= 9: @@ -624,6 +710,18 @@ def params(self, pipeline: list[str]) -> dict: for n in takers: base = stages[n].PARAMS["png"] out.setdefault(n, {})["png"] = {**(base if isinstance(base, dict) else {}), "level": level} + fmt = getattr(self.args, "audio_format", None) + if fmt is not None: + for n in [n for n in pipeline if "audio" in stages[n].PARAMS]: + out.setdefault(n, {})["audio"] = fmt + fmt = getattr(self.args, "movie_format", None) + if fmt is not None: + for n in [n for n in pipeline if "format" in stages[n].PARAMS]: + out.setdefault(n, {})["format"] = fmt + source = getattr(self.args, "fonts", None) + if source is not None: + for n in [n for n in pipeline if "fonts" in stages[n].PARAMS]: + out.setdefault(n, {})["fonts"] = source level = getattr(self.args, "flac_level", None) if level is not None: if not 0 <= level <= 12: @@ -633,18 +731,6 @@ def params(self, pipeline: list[str]) -> dict: usage(self.args, "--flac-level: no stage of the pipeline encodes FLAC") for n in takers: out.setdefault(n, {})["flac"] = {**stages[n].PARAMS["flac"], "level": level} - only = getattr(self.args, "only_class", None) - if only: - classes = sorted({c.strip() for c in only.split(",") if c.strip()}) - takers = [n for n in pipeline if "classes" in stages[n].PARAMS] - if not classes or not takers: - usage( - self.args, - "--only-class: " - + ("no class given" if not classes else "no stage of the pipeline selects classes"), - ) - for n in takers: - out.setdefault(n, {})["classes"] = classes for n, p in out.items(): try: stages[n].normalize(p) @@ -652,6 +738,16 @@ def params(self, pipeline: list[str]) -> dict: usage(self.args, str(e)) return out + def classes(self) -> list[str] | None: + """The classes of --only-class (the root objects of the units to export), None for every class.""" + only = getattr(self.args, "only_class", None) + if only is None: + return None + classes = sorted({c.strip() for c in only.split(",") if c.strip()}) + if not classes: + usage(self.args, "--only-class: no class given") + return classes + def workers(self) -> int: w = getattr(self.args, "workers", None) return max(0, w) if w is not None else usable_cpus() @@ -668,21 +764,59 @@ def facts(self, pipeline: list[str], fetch: bool, background: bool = False) -> d """The planning facts (module documentation). `fetch`: fetch the files whose content is not known yet (else they are Pending); what cannot be read is in problems() (after wait()). `background`: the raw files are fetched while the caller goes on (the fact waits for them when it is read).""" + from ..pipeline import builtins + index = self.index() - sel = select(index, self.selection) + try: + builtin = builtins.inputs(self.depot, self.apk) + except (OSError, zipfile.BadZipFile): + raise ConfigError(f"setting paths.apk: {self.apk} is not a readable APK") from None + sel = select(index, self.selection, builtin) self._problems = [] bundles = {b["stable"]: b for b in index["bundles"]} - inputs = self._inputs("bundle", [bundles[s] for s in sel.census], fetch) + inputs = self._inputs("bundle", [bundles[s] for s in sel.census if s not in builtin], fetch) + inputs = inputs.plus(builtin) sel.selected = [s for s in sel.selected if s in inputs] # files that cannot be read: problems() + products = [n for n in pipeline if self.installed.sources[n].product] + readers = [n for n in products if self.installed.stages[n].MASTER] # products that read master tables facts = { "catalogs": self.catalogs_fact(), "index": index, "selected": sel.selected, - "master": self.master() if any(n.startswith("view.") for n in pipeline) else None, + "master": self.master() if readers or any(n.startswith("view.") for n in pipeline) else None, "views": sorted(n[5:] for n in pipeline if n.startswith("view.")), "bundles": inputs, + "classes": self.classes(), "raw": Inputs({}), + "product_inputs": self.product_inputs, } + if {"story.fonts", "live.ui"}.intersection(products): + from ..source.languages import LANGUAGES + + font_paths = {code: self.cfg.path("paths.fonts", code) for code in (*LANGUAGES, "emoji")} + font_paths.update({code: Path(path) for code, path in (getattr(self.args, "font", None) or [])}) + facts["font_files"] = {code: path for code, path in font_paths.items() if path is not None} + if products: + from ..pipeline import assemble + + if self.products is not None: + chosen = {n: self.products.get(n, []) for n in products} + if facts["master"] is None and any(chosen[n] for n in readers): + raise self.cfg.missing(*self.cfg.master(self.cfg.get("catalog", "region"))) + else: # without master data, the products that read it have no subjects (as the views) + held = held_keys(index, sel.selected) + chosen = { + n: [] + if n in readers and facts["master"] is None + else self.installed.stages[n].select(held, Env(self.depot, facts)) + for n in products + } + facts["products"] = chosen + facts["roots"] = assemble.request(self.installed.stages, chosen, Env(self.depot, facts)) + members = {m for n in products if chosen[n] for m in self.installed.stages[n].APK} + facts["apk"] = builtins.apk_files(self.depot, self.apk, members) + if self.products is not None or not set(self.layouts) & set(OBJECT_LAYOUTS): + facts["selected"] = [] # products alone: what they reach if any(n in pipeline for n in RAW_STAGES): from ..pipeline.cristages import boot_input @@ -695,7 +829,7 @@ def facts(self, pipeline: list[str], fetch: bool, background: bool = False) -> d facts["raw"] = Later(self._later) else: facts["raw"] = job[0](*job[1:]) - facts["boot"] = boot_input(self.store, self.apk) + facts["boot"] = boot_input(builtin) self.selected = sel self._facts, self._names = facts, {} return facts @@ -713,14 +847,15 @@ def problems(self) -> list[dict]: """Files of the selection that could not be read: {kind, stable, name, code, message}, sorted.""" return sorted(self._problems, key=lambda p: (p["kind"], p["stable"])) - def _cache_rel(self, kind: str, entry: dict, loc: dict) -> str: + def _download_rel(self, kind: str, entry: dict, loc: dict) -> str: + """Where the file of a catalog entry is in the depot's downloads/ (the path of its "download" locator).""" from ..source.addressables import remote_path prefix = "" if self.version.get("source"): from ..source.jp import Source - prefix = Source.from_dict(self.version["source"]).cache_dir(Path(".")).as_posix() + "/" + prefix = Source.from_dict(self.version["source"]).download_dir(Path(".")).as_posix() + "/" if not entry.get("remote", True) and self.version.get("apk"): prefix += "apk/" + self.version["apk"]["sha256"] + "/" if kind == "bundle": @@ -739,15 +874,15 @@ def _inputs(self, kind: str, entries: list[dict], fetch: bool) -> Inputs: out, todo = {}, [] for e in entries: loc = locs[e["location"]] - rel = self._cache_rel(kind, e, loc) - locators = ([{"kind": "cache", "path": rel}] if rel else []) + [ + rel = self._download_rel(kind, e, loc) + locators = ([{"kind": "download", "path": rel}] if rel else []) + [ {"kind": "catalog", "catalog": self.version["id"], "location": e["location"]} ] - path = self.cache / rel if self.cache is not None and rel else None + path = self.depot.downloads / rel if rel else None if path is not None and path.is_file() and path.stat().st_size > 0: todo.append((e, locators, path)) continue - known = self.store.named(memo_kind, memo_prefix + e["name"]) if e["name"] != e["stable"] else None + known = self.depot.named(memo_kind, memo_prefix + e["name"]) if e["name"] != e["stable"] else None if known is not None: # a hashed file name: its recorded identity holds out[e["stable"]] = Input(kind, known[0], known[1], e["name"], tuple(locators)) elif not e["remote"] and self.apk is None: @@ -766,7 +901,7 @@ def one(job): try: if path is None: path = self.fetcher.fetch_location(self.version["id"], e["location"]) - sha, size = self.store.identify(path, memo_kind, memo_prefix + e["name"]) + sha, size = self.depot.identify(path, memo_kind, memo_prefix + e["name"]) except ConfigError: raise except KeyError as x: # not in the APK @@ -809,8 +944,7 @@ def explain(self, pipeline: list[str], params: dict, orch=None, layouts=(), plac ctx = self.context() c = ctx["catalog"] lines = [ - f"store: {self.root}", - f"cache: {self.cache if self.cache is not None else 'not set'}", + f"depot: {self.root}", f"context: region {ctx['region'] or '-'}, language {ctx['language'] or '-'}, catalog " f"{c['label'] or '-'} ({c['id'][:12]}), APK catalog {(c['apkCatalogSha'] or '-')[:12]}", f"selection: {', '.join(self.selection)}", @@ -837,13 +971,13 @@ def explain(self, pipeline: list[str], params: dict, orch=None, layouts=(), plac # ---------------------------------------------------------------- plan def previous(self, since: str | None) -> dict | None: if not since: - return self.store.latest_run(self.selection) - doc = self.store.run(since) if contract.is_sha256(since) else None + return self.depot.latest_run(self.selection) + doc = self.depot.run(since) if contract.is_sha256(since) else None if doc is None: runs = sorted(p.name[:-5] for p in (self.root / "runs").glob("*.json") if p.name.startswith(since)) if len(runs) != 1: usage(self.args, f"--since {since}: {'no run' if not runs else f'{len(runs)} runs'} with this id") - doc = self.store.run(runs[0]) + doc = self.depot.run(runs[0]) return doc def plan(self, pipeline: list[str], params: dict, *, since=None, out=None, layouts=()): @@ -853,7 +987,7 @@ def plan(self, pipeline: list[str], params: dict, *, since=None, out=None, layou p = plan_mod.plan( self.scheduled, pipeline, - self.store, + self.depot, params=params, facts=facts, previous=self.previous(since), @@ -867,7 +1001,7 @@ def plan(self, pipeline: list[str], params: dict, *, since=None, out=None, layou hits = [(n["id"], n["key"]) for n in p.doc["nodes"] if n["status"] == "hit" and self._output(n["stage"])] placed = self.layout_input(dict(hits)) for tid, key in hits: - placed.add(tid, self.store.committed(key, tid)) + placed.add(tid, self.depot.committed(key, tid)) for name, d in layout_dirs(Path(out), layouts).items(): entries, _ = self.layout_entries(name, placed, store_derived=False) p.doc["summary"]["layouts"][name] = layout.diff(d, entries) @@ -885,7 +1019,7 @@ def layout_input(self, tasks: dict) -> LayoutInput: stages, asked = self.installed.stages, _Asked() makers = sorted(t for t in tasks if hasattr(stages[contract.parse_task_id(t)[0]], "derived")) for tid in makers: - stages[contract.parse_task_id(tid)[0]].derived(tid, self.store.result(tasks[tid]), self.store, asked) + stages[contract.parse_task_id(tid)[0]].derived(tid, self.depot.result(tasks[tid]), self.depot, asked) return LayoutInput(asked.objects_asked, makers) def layout_entries(self, name: str, placed: LayoutInput, *, store_derived: bool = True) -> tuple[list[dict], dict]: @@ -894,9 +1028,13 @@ def layout_entries(self, name: str, placed: LayoutInput, *, store_derived: bool from ..pipeline import layout stages = self.installed.stages + if name not in OBJECT_LAYOUTS: + return self._product_entries(name, placed, store_derived) sources, named = [], [] for tid, doc in placed.results: stage_name, subject = contract.parse_task_id(tid) + if self.installed.sources[stage_name].product: + continue stage = stages[stage_name] names = self.names(stage_name).get(subject) if name == "original" else None if names: @@ -910,12 +1048,12 @@ def layout_entries(self, name: str, placed: LayoutInput, *, store_derived: bool if name != "original": return layout.entries(name, sources) derives = lambda s: hasattr(stages[contract.parse_task_id(s.task)[0]], "derived") # noqa: E731 - roots = {} # every object of a bundle under its export task's root - for s in sources: - if s.task.startswith(EXPORT_STAGE + ":") and s.root is not None: - for f in s.result["files"]: - roots.setdefault(f, s.root) - entries, report = layout.entries(name, [s for s in sources if not derives(s)], case=case, object_roots=roots) + table = self.units() + files = table.files if table is not None else {} + roots = {f: f"_bundles/{b}" for f, b in files.items()} # every object of a bundle under one root + entries, report = layout.entries( + name, [s for s in sources if not derives(s)], case=case, bundles=files, object_roots=roots + ) report["names"] = named extra = self._derived(sources, entries, store_derived, placed.objects) taken = {e["path"].casefold() for e in entries} @@ -925,10 +1063,55 @@ def layout_entries(self, name: str, placed: LayoutInput, *, store_derived: bool taken.add(e["path"].casefold()) return sorted(entries + extra, key=lambda e: (e["path"], e["id"])), report + def _product_entries(self, name: str, placed: LayoutInput, store: bool) -> tuple[list[dict], dict]: + """The entries of a product layout (`product`: manifests and files; `files`: the products' own files at + their paths) for the product results given to `placed`, and its report.""" + from ..pipeline import layout + + results = [(t, d) for t, d in placed.results if self.installed.sources[contract.parse_task_id(t)[0]].product] + if name == "files": + return layout.product_files(d for _, d in results), {"collisions": [], "products": len(results)} + products = [] + for tid, doc in results: + stage_name, subject = contract.parse_task_id(tid) + rec = next(a for a in doc["artifacts"] if a["id"] == contract.artifact_id(tid, layout.PRODUCT_TABLE)) + table = contract.loads(self.depot.read(rec["content"]["sha256"])) + body = contract.encode(layout.product_manifest(tid, doc, table)) + sha = self.depot.put(body) if store else contract.sha256(body) + manifest = {"id": contract.artifact_id(tid, "manifest"), "sha256": sha, "size": len(body)} + products.append((self.installed.stages[stage_name].manifest(subject), manifest, doc, table)) + entries = layout.product_entries(products) + indexed = {} + for tid, doc in results: + stage_name, subject = contract.parse_task_id(tid) + if hasattr(self.installed.stages[stage_name], "layout_index"): + indexed.setdefault(stage_name, []).append((subject, doc)) + for name, records in sorted(indexed.items()): + path, body = self.installed.stages[name].layout_index(records, self.depot) + sha = self.depot.put(body) if store else contract.sha256(body) + entries.append({"path": path, "sha256": sha, "size": len(body), "id": f"{name}:library#index"}) + return entries, {"collisions": [], "products": len(products)} + def done(self, tasks: dict) -> None: """The tasks whose results the layouts place, {task id: key} (after a run: every task with a result).""" self._done, self._names = dict(tasks), {} + def units(self): + """The unit table of the request (pipeline.units) for the tasks given to done(), a census without a result + counted as failed; None when the run has no censuses.""" + from ..pipeline import units + + env = Env(self.depot, self._facts) + for tid, key in sorted(self._done.items()): + env.done(tid, key) + census = [contract.task_id("unity.census", b) for b in self._facts.get("bundles") or ()] + if not any(t in self._done for t in census): + return None + for t in census: + if t not in self._done: + env.waiting(t, failed=True) + return units.table(env) + def names(self, stage_name: str) -> dict: """{subject: catalog names} of a stage that names its tasks' files (stage.names(env): the CRI stages root a content at each catalog name that refers to it, bundle-held content through the unity.export results; @@ -938,7 +1121,7 @@ def names(self, stage_name: str) -> dict: stage = self.installed.stages[stage_name] got = {} if hasattr(stage, "names"): - env = Env(self.store, self._facts) + env = Env(self.depot, self._facts) for tid, key in sorted(self._done.items()): env.done(tid, key) got = stage.names(env) @@ -958,8 +1141,8 @@ def _derived(self, sources, entries: list[dict], store_derived: bool, objects: d out = [] for s in makers: stage = stages[contract.parse_task_id(s.task)[0]] - for rel, data in stage.derived(s.task, s.result, self.store, paths): - sha = self.store.put(data) if store_derived else contract.sha256(data) + for rel, data in stage.derived(s.task, s.result, self.depot, paths): + sha = self.depot.put(data) if store_derived else contract.sha256(data) out.append( { "path": layout.escape_path(rel.split("/")), @@ -1000,11 +1183,7 @@ def add(self, tid: str, doc: dict) -> None: if obj: slim["provenance"] = {"object": obj} arts.append(slim) - files = {} - if tid.startswith(EXPORT_STAGE + ":"): - for it in doc["items"]: - files.setdefault(contract.parse_object_id(it["object"])[0], None) - self.results.append((tid, {"artifacts": arts, "files": list(files)})) + self.results.append((tid, {"artifacts": arts})) class _Asked: @@ -1047,6 +1226,10 @@ def waiting(self) -> list[str]: """The subjects not fetched yet.""" return [k for k, v in self._items.items() if isinstance(v, Pending)] + def plus(self, items: dict) -> Inputs: + """These inputs and `items` ({subject: Input}).""" + return Inputs({**self._items, **items}) + class Later(Mapping): """A mapping a background job makes (Future): reading it waits for the job.""" @@ -1100,23 +1283,19 @@ def parse_layouts(spec: str) -> list[str]: # ---------------------------------------------------------------- export class Totals: """What export reports of the results besides the run's counts, from its one reading of each (Run.scan): the - census totals of `subjects` ({class: objects}, the census artifacts' facts) and the results of the view tasks - (small; their master data tables and gaps).""" + census totals (`classes`: {class: objects} of the requested units, pipeline.units) and the results of the view + tasks (small; their master data tables and gaps).""" - def __init__(self, subjects): - self.want = {contract.task_id("unity.census", s) for s in subjects} - self.classes: Counter = Counter() + def __init__(self, classes: dict | None): + self.classes = None if classes is None else dict(sorted(classes.items())) self.views: list[tuple[str, dict]] = [] def add(self, tid: str, doc: dict) -> None: - if tid in self.want: - for a in doc["artifacts"]: - self.classes.update(a["semantics"].get("facts", {}).get("classes", {})) if tid.startswith("view."): self.views.append((tid, doc)) - def census(self) -> dict[str, int]: - return dict(sorted(self.classes.items())) + def census(self) -> dict[str, int] | None: + return self.classes def view_gaps(results) -> int: @@ -1129,7 +1308,7 @@ def view_gaps(results) -> int: def _write_report(d: Path, name: str, doc) -> Path: - from ..pipeline.store import write_file + from ..pipeline.depot import write_file p = d / name write_file(p, contract.encode(doc)) @@ -1137,19 +1316,22 @@ def _write_report(d: Path, name: str, doc) -> Path: def cmd_export(args, cfg): - from ..pipeline import layout - from ..pipeline.orchestrate import Orchestrator, failure - try: names = parse_layouts(args.layout) except ValueError as e: usage(args, str(e)) use(cfg) # the tools the atoms name are part of the keys - ws = Workspace(args, cfg) - pipeline = ws.pipeline() - params = ws.params(pipeline) - orch = Orchestrator( - ws.store, + export(args, cfg, Workspace(args, cfg, layouts=names)) + + +def request_orchestrator(ws, cfg): + """The shared executor for product requests.""" + from ..pipeline.downloads import BundleDownloads + from ..pipeline.orchestrate import Orchestrator + + lease, budget = getattr(ws.args, "lease", None), Session(cfg).downloads_budget() + return Orchestrator( + ws.depot, ws.scheduled, workers=ws.workers(), memory=ws.memory(), @@ -1158,32 +1340,41 @@ def cmd_export(args, cfg): initializer=_worker_init, initargs=(cfg,), log=ws.log, + on_batch_done=BundleDownloads(ws.depot, lease, budget) if lease is not None and budget is not None else None, ) - out = Path(args.out) - placement = None - if not args.dry_run: # before any work: an explicit method that cannot work stops it - try: - placement = layout.probe(out, ws.root / "cas" / "sha256", link_mode(args, cfg)) - except layout.LayoutError as e: - usage(args, str(e)) - if args.explain: - for line in ws.explain(pipeline, params, orch, names, placement): - print(line, file=sys.stderr) - if placement is None: # --dry-run - sys.stdout.write(ws.plan(pipeline, params, out=out, layouts=names).text()) - return + + +def run_request(args, cfg, ws: Workspace, expand=None, prepared=None, *, selection_prefix=()): + """Execute a product request and optional expansion without materializing an output layout.""" + from ..pipeline import builtins + from ..pipeline.downloads import BundleDownloads + from ..pipeline.orchestrate import failure + + if prepared is None: + pipeline = ws.pipeline() + params = ws.params(pipeline) + orch = request_orchestrator(ws, cfg) + else: + pipeline, params, orch = prepared + if expand is not None: + orch.on_batch_done = None # the episode supplies the roots of the complete product request ws.run_index() facts = ws.facts(pipeline, fetch=True, background=True) sel = ws.selected + own = sum(1 for s in sel.census if builtins.is_builtin(s)) + picked = sum(1 for s in sel.selected if builtins.is_builtin(s)) ws.log( - f"{len(sel.selected)} bundles selected ({len(sel.census)} with their dependencies), " - f"{len(sel.raw)} raw files" + f"{len(sel.selected) - picked} bundles selected ({len(sel.census) - own} with their dependencies), " + + (f"{picked} of the APK's {own} built-in files, " if own else "") + + f"{len(sel.raw)} raw files" + (" (fetched while the bundle stages run)" if isinstance(facts["raw"], Later) else "") + f"; {sum(p['kind'] == 'bundle' for p in ws.problems())} bundles not readable" ) aborted = None try: - run = orch.run(pipeline, params=params, facts=facts, context=ws.context(), selection=ws.selection) + run = orch.run( + pipeline, params=params, facts=facts, context=ws.context(), selection=[*selection_prefix, *ws.selection] + ) except ConfigError as e: run = getattr(e, "run", None) if run is None: @@ -1200,6 +1391,15 @@ def cmd_export(args, cfg): aborted = aborted or str(e) if isinstance(facts["raw"], Later) and aborted is None: ws.log(f"{sum(p['kind'] == 'raw' for p in ws.problems())} raw files not readable") + released = orch.on_batch_done + if isinstance(released, BundleDownloads) and released.removed: + run.log.append({"event": "downloads.released", "files": released.removed, "bytes": released.bytes_removed}) + ws.log(f"downloads: released {released.removed} bundle files ({released.bytes_removed} bytes)") + if expand is not None and aborted is None: + more = expand(run) + if more is not None: + ws.request(*more) + return run_request(args, cfg, ws, selection_prefix=selection_prefix) stage_of = {"bundle": "unity.census", "raw": next((n for n in RAW_STAGES if n in pipeline), "cri.audio")} for p in ws.problems(): if p["code"] == "source.error": @@ -1212,11 +1412,39 @@ def cmd_export(args, cfg): p["name"], ) ) + return run, orch, aborted + + +def export(args, cfg, ws: Workspace, expand=None) -> None: + """Run the request of `ws` and place its layouts (ws.layouts) in args.out (cmd_export; export live2d). `expand`: + called with the run when it was not aborted, gives the request of a further run ((products, selection) for + Workspace.request) or None; the last run places the layouts (export story: the products a story names).""" + from ..pipeline import layout + + names = ws.layouts + pipeline = ws.pipeline() + params = ws.params(pipeline) + orch = request_orchestrator(ws, cfg) + out = Path(args.out) + placement = None + if not args.dry_run: # before any work: an explicit method that cannot work stops it + try: + placement = layout.probe(out, ws.root / "cas" / "sha256", link_mode(args, cfg)) + except layout.LayoutError as e: + usage(args, str(e)) + if args.explain: + for line in ws.explain(pipeline, params, orch, names, placement): + print(line, file=sys.stderr) + if placement is None: # --dry-run + sys.stdout.write(ws.plan(pipeline, params, out=out, layouts=names).text()) + return + run, orch, aborted = run_request(args, cfg, ws, expand, (pipeline, params, orch)) done = {tid: t["key"] for tid, t in run.tasks.items() if t["status"] != "failed"} ws.done(done) output = {tid for tid in done if ws._output(contract.parse_task_id(tid)[0])} placed = ws.layout_input({tid: done[tid] for tid in output}) - totals = Totals(sel.selected) + table = ws.units() + totals = Totals(table.classes if table is not None else None) def visit(tid, doc): totals.add(tid, doc) @@ -1229,8 +1457,8 @@ def visit(tid, doc): written, shas, unplaced = {}, {}, 0 for name, d in layout_dirs(out, names).items(): entries, report = ws.layout_entries(name, placed) - w = layout.materialize(d, name, {}, entries, layout.from_store(ws.store), link=placement[0]) - shas[name] = w["sha256"] + w = layout.materialize(d, name, {}, entries, layout.from_depot(ws.depot), link=placement[0]) + shas[name] = ws.depot.put(contract.encode(w["manifest"])) written[name] = { "dir": str(d), "files": len(w["manifest"]["entries"]), @@ -1306,7 +1534,7 @@ def cmd_plan(args, cfg): except ValueError as e: usage(args, str(e)) use(cfg) # the tools the atoms name are part of the keys - ws = Workspace(args, cfg) + ws = Workspace(args, cfg, layouts=layouts) pipeline = ws.pipeline() params = ws.params(pipeline) if args.explain: @@ -1315,8 +1543,8 @@ def cmd_plan(args, cfg): if args.census: ws.run_index() facts = ws.facts(["catalog.index", "unity.census"], fetch=True) - Orchestrator( - ws.store, + run = Orchestrator( + ws.depot, ws.scheduled, workers=ws.workers(), memory=ws.memory(), @@ -1325,10 +1553,16 @@ def cmd_plan(args, cfg): initializer=_worker_init, initargs=(cfg,), log=ws.log, - ).run(["catalog.index", "unity.census"], facts=facts) + ).run( + ["catalog.index", "unity.census"], + facts=facts, + context=ws.context(), + selection=["depot.plan.census", *ws.selection], + ) + run.write() p = ws.plan(pipeline, params, since=args.since, out=args.out, layouts=layouts) if args.emit_tasks: - files = p.emit(args.emit_tasks) + files = p.emit(args.emit_tasks, depot=ws.depot, context=ws.context(), selection=ws.selection) ws.log(f"{len(files)} task descriptions in {args.emit_tasks}") if args.why: common.report(args, why(ws, p, args.why, args.since), why_lines) @@ -1348,7 +1582,7 @@ def why(ws: Workspace, p, tid: str, since=None) -> dict: new = None if node["key"] is not None: t = next((t for t in p.tasks if t.key == node["key"]), None) - doc = None if t is not None else ws.store.result(node["key"]) + doc = None if t is not None else ws.depot.result(node["key"]) new = t.key_parts() if t is not None else (doc["keyParts"] if doc else None) prev = ws.previous(since) old_task = next((t for t in prev.get("tasks", []) if t["id"] == tid), None) if prev is not None else None @@ -1356,7 +1590,7 @@ def why(ws: Workspace, p, tid: str, since=None) -> dict: if prev is not None and old_task is not None: previous = {"run": prev["id"], "key": old_task.get("key"), "status": old_task["status"]} if old_task.get("key"): - doc = ws.store.result(old_task["key"]) + doc = ws.depot.result(old_task["key"]) old = doc["keyParts"] if doc else None return { "task": tid, @@ -1379,7 +1613,7 @@ def why_lines(r: dict) -> list[str]: ] -# ---------------------------------------------------------------- store run +# ---------------------------------------------------------------- depot run def run_lines(r: dict) -> list[str]: lines = [] for t in r["tasks"]: @@ -1398,8 +1632,8 @@ def _task_files(items) -> list[Path]: def cmd_run_stage(args, cfg): - from ..pipeline.orchestrate import run_one - from ..pipeline.store import Store + from ..pipeline.depot import open_depot + from ..pipeline.orchestrate import Run, run_one problems, tasks = [], [] for f in _task_files(args.tasks): @@ -1423,25 +1657,31 @@ def cmd_run_stage(args, cfg): for m in problems: print(f"nnnotes: incompatible task: {m}", file=sys.stderr) sys.exit(EXIT_INCOMPATIBLE) - root = Session(cfg).store_root() - cache = cfg.path("paths", "cache") - store = Store(root, cache=cache, fetch=CatalogFetcher(root, cache, cfg) if args.fetch else None) + root = Session(cfg).depot_root() + depot = open_depot(cfg, fetch=CatalogFetcher(root, cfg) if args.fetch else None) use(cfg) - out, bad = [], 0 + out, bad, runs = [], 0, [] for t in tasks: - r = run_one(t, store, inst.stages, args.force) + r = run_one(t, depot, inst.stages, args.force) if r["ok"]: out.append({"id": t.id, "key": t.key, "status": r["status"], "result": r["resultStatus"]}) bad += r["resultStatus"] != "ok" else: out.append({"id": t.id, "key": t.key, "status": "failed", "code": r["code"], "message": r["message"]}) bad += 1 - if r["code"] == "config": - break + run = Run(depot, dict(t.context), ["depot.run", t.id], {t.stage: dict(t.params)}, {t.stage: t.version}) + run.tasks[t.id] = {k: out[-1][k] for k in ("id", "key", "status")} + runs.append(run.write()["id"]) + if not r["ok"] and r["code"] == "config": + break n = Counter(o["status"] for o in out) common.report( args, - {"tasks": out, "summary": {"tasks": len(tasks), "hit": n["hit"], "ran": n["ran"], "failed": n["failed"]}}, + { + "tasks": out, + "runs": runs, + "summary": {"tasks": len(tasks), "hit": n["hit"], "ran": n["ran"], "failed": n["failed"]}, + }, run_lines, ) if bad: @@ -1449,9 +1689,9 @@ def cmd_run_stage(args, cfg): # ---------------------------------------------------------------- catalog versions -def addresses_of(store, vid: str) -> dict | None: +def addresses_of(depot, vid: str) -> dict | None: """The address table (link.addresses) of the latest run over a catalog version, None when no run has one.""" - runs = sorted((store.root / "runs").glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) + runs = sorted((depot.root / "runs").glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True) for p in runs: try: doc = contract.loads(p.read_bytes()) @@ -1467,10 +1707,10 @@ def addresses_of(store, vid: str) -> dict | None: ), None, ) - res = store.result(t["key"]) if t else None + res = depot.result(t["key"]) if t else None if res: (a,) = res["artifacts"] - return contract.loads(store.read(a["content"]["sha256"])) + return contract.loads(depot.read(a["content"]["sha256"])) return None @@ -1486,7 +1726,7 @@ def _gib(s: str) -> float: def run_args(c) -> None: - """The selection and parameters shared by `export assets` and `store plan`.""" + """The selection and parameters shared by `export assets` and `depot plan`.""" c.add_argument( "--select", action="append", @@ -1501,7 +1741,7 @@ def run_args(c) -> None: c.add_argument( "--stored-catalog", metavar="LABEL|SHA", - help="a catalog version of the store (`catalog versions`; default: the current catalog)", + help="a catalog version of the depot (`catalog versions`; default: the current catalog)", ) c.add_argument("--png-level", type=int, metavar="N", help="PNG compression level 0-9 (changes the output)") c.add_argument( @@ -1511,7 +1751,7 @@ def run_args(c) -> None: help="FLAC compression level 0-12 of the CRI audio (default 8; every level decodes to the same " "samples; changes the files)", ) - c.add_argument("--only-class", metavar="C,...", help="export only objects of these classes") + c.add_argument("--only-class", metavar="C,...", help="export only the units whose root object is of these classes") c.add_argument( "--workers", type=nonnegative, @@ -1525,5 +1765,9 @@ def run_args(c) -> None: help="memory budget of the running tasks and workers (default: 80 %% of the physical memory)", ) c.add_argument("--fetch-workers", type=int, metavar="N", help=f"parallel downloads (default {FETCH_WORKERS})") - c.add_argument("--layout", default="original", help="original, cas or original,cas (default original)") + c.add_argument( + "--layout", + default="original", + help="original, cas, product, comma-separated (default original; product: the products of the selection)", + ) c.add_argument("--explain", action="store_true", help="print every setting of the run first") diff --git a/src/nnnotes/cli/browse.py b/src/nnnotes/cli/browse.py index 9e384f6..93f2c79 100644 --- a/src/nnnotes/cli/browse.py +++ b/src/nnnotes/cli/browse.py @@ -49,13 +49,14 @@ class Region: class Server(ThreadingHTTPServer): - """The browser's HTTP server: its regions, the bundle key, the cache, and the catalogs read so far.""" + """The browser's HTTP server: its regions, the bundle key, the depot's downloads/, and the catalogs read so + far.""" - def __init__(self, address, regions: list[Region], bundle_key: BundleKey, cache: Path): + def __init__(self, address, regions: list[Region], bundle_key: BundleKey, downloads: Path): super().__init__(address, Handler) self.regions = {r.name: r for r in regions} self.bundle_key = bundle_key - self.cache = Path(cache) + self.downloads = Path(downloads) self.catalogs: dict[tuple[str, str], tuple[dict, dict]] = {} self.jp_catalogs: dict[tuple[str, str], Catalog] = {} @@ -87,12 +88,14 @@ def catalog(self, region: Region, language: str): key = (region.name, language) if key not in self.server.catalogs: if region.config is not None and region.config.provider(region.name) == "jp": - cat = open_catalog(region.config, region.name, bundle_key=self.server.bundle_key) + cat = open_catalog(region.config, region.name, self.server.downloads, bundle_key=self.server.bundle_key) self.server.catalogs[key] = browse(cat.entries) self.server.jp_catalogs[key] = cat return self.server.catalogs[key] version = Session(region.config).catalog_version(region.name) if region.config is not None else "main" - cat = Catalog.load(language, self.server.cache / "catalogs" / region.name, cdn=region.cdn, version=version) + cat = Catalog.load( + language, self.server.downloads / "catalogs" / region.name, cdn=region.cdn, version=version + ) self.server.catalogs[key] = browse(cat.entries) return self.server.catalogs[key] @@ -174,10 +177,10 @@ def do_GET(self): self.listing(region.label + "/" + language + "/" + prefix, rows) -def serve(regions: list[Region], bundle_key: BundleKey, cache: Path, port: int, host: str = "127.0.0.1") -> None: - """Browse the catalogs of `regions` and download decrypted bundles on `host:port` (catalogs cached under - /catalogs//).""" - server = Server((host, port), regions, bundle_key, cache) +def serve(regions: list[Region], bundle_key: BundleKey, downloads: Path, port: int, host: str = "127.0.0.1") -> None: + """Browse the catalogs of `regions` and download decrypted bundles on `host:port` (catalogs downloaded into + `downloads`/catalogs//).""" + server = Server((host, port), regions, bundle_key, downloads) print(f"nnnotes catalog browse: serving on {host}:{port}", flush=True) try: server.serve_forever() diff --git a/src/nnnotes/cli/bundle.py b/src/nnnotes/cli/bundle.py index 72a94bb..56375da 100644 --- a/src/nnnotes/cli/bundle.py +++ b/src/nnnotes/cli/bundle.py @@ -1,21 +1,49 @@ -"""`nnnotes bundle`: fetch bundles into the cache.""" +"""`nnnotes bundle`: copy the decrypted bundles of keys out of the depot's downloads.""" from __future__ import annotations +import os +import shutil +from pathlib import Path + +from ..core.cache import temp_path from ..source.session import Session -from .common import command, group, known_keys, report +from .common import command, group, known_keys, out_arg, report def cmd_pull(args, cfg): cat = Session(cfg).catalog() known_keys(args, cat, args.keys) - files = {key: [str(p) for p in cat.fetch_key(key)] for key in args.keys} + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + copied: dict[Path, Path] = {} + files = {} + for key in args.keys: + for p in (paths := cat.fetch_key(key)): + if p not in copied: + copied[p] = _copy(p, out / p.name) + files[key] = [str(copied[p]) for p in paths] report(args, {"keys": files}, lambda r: [p for ps in r["keys"].values() for p in ps]) +def _copy(src: Path, dst: Path) -> Path: + tmp = temp_path(dst) + try: + shutil.copyfile(src, tmp) + os.replace(tmp, dst) + except BaseException: + tmp.unlink(missing_ok=True) + raise + return dst + + def register(sub) -> None: bs = group(sub, "bundle", "the bundles of the catalog") c = command( - bs, "pull", cmd_pull, "fetch the bundle closure of keys into the cache (prints the cached bundle files)" + bs, + "pull", + cmd_pull, + "fetch the bundle closure of keys and copy the decrypted bundles to a directory (prints the bundle files)", ) c.add_argument("keys", nargs="+", metavar="KEY", help="addressable keys (`catalog keys` lists them)") + out_arg(c, "directory the bundles are copied to, under their file names") diff --git a/src/nnnotes/cli/catalog.py b/src/nnnotes/cli/catalog.py index bc055ca..d49fd60 100644 --- a/src/nnnotes/cli/catalog.py +++ b/src/nnnotes/cli/catalog.py @@ -1,4 +1,4 @@ -"""`nnnotes catalog`: the region's addressable keys, a local catalog browser, and the catalog versions of the store +"""`nnnotes catalog`: the region's addressable keys, a local catalog browser, and the catalog versions of the depot (import, fetch, list, diff).""" from __future__ import annotations @@ -40,14 +40,16 @@ def cmd_browse(args, cfg): if not langs: raise cfg.missing(f"servers.{r}", "languages") regions.append(Region(r, cfg.get(f"servers.{r}", "name") or r, cfg.cdn(r), langs, cfg)) - serve(regions, Session(cfg).bundle_key(), cfg.require_path("paths", "cache"), args.port, args.host) + serve(regions, Session(cfg).bundle_key(), Session(cfg).downloads(), args.port, args.host) -# ---------------------------------------------------------------- catalog versions of the store +# ---------------------------------------------------------------- catalog versions of the depot def _db(cfg): from ..pipeline.catalogdb import CatalogDB + from ..pipeline.depot import open_depot - return CatalogDB(Session(cfg).store_root()) + depot = open_depot(cfg) + return CatalogDB(depot.root, remote=depot.remote) def version_line(v: dict) -> str: @@ -145,7 +147,7 @@ def cmd_fetch(args, cfg): if cfg.provider(region) == "jp": from ..source.jp import open_catalog - cat = open_catalog(cfg, region, apk=cfg.path("paths", "apk")) + cat = open_catalog(cfg, region, Session(cfg).downloads(), apk=cfg.path("paths", "apk")) src, source = cat.sources(), cat.source assert source is not None # a JP catalog names its version source v = _db(cfg).add( @@ -163,7 +165,7 @@ def cmd_fetch(args, cfg): cdn = cfg.cdn(region) language = cfg.require("catalog", "language") version = Session(cfg).catalog_version(region) - filename = Catalog.cache_file(language, Path("."), version=version).name + filename = Catalog.file_name(language, version=version) try: data, hash_text = catalogdb.fetch(cdn, language, version=version) except OSError as e: # URLError, HTTPError, timeouts (the message has no URL) @@ -190,7 +192,7 @@ def cmd_fetch(args, cfg): def cmd_diff(args, cfg): from ..pipeline import catalogdb - from ..pipeline.store import Store + from ..pipeline.depot import open_depot from .assets import addresses_of db = _db(cfg) @@ -200,9 +202,9 @@ def cmd_diff(args, cfg): vs.append(db.get(ref)) except (KeyError, ValueError) as e: usage(args, f"{ref}: {e.args[0] if e.args else e}") - store = Store(Session(cfg).store_root()) + depot = open_depot(cfg) a, b = vs - d = catalogdb.diff(db.index(a), db.index(b), addresses_of(store, a["id"]), addresses_of(store, b["id"])) + d = catalogdb.diff(db.index(a), db.index(b), addresses_of(depot, a["id"]), addresses_of(depot, b["id"])) if args.json: report(args, d) else: @@ -210,14 +212,14 @@ def cmd_diff(args, cfg): def register(sub) -> None: - cs = group(sub, "catalog", "the region's catalog: its keys, a local browser, and the catalog versions of the store") + cs = group(sub, "catalog", "the region's catalog: its keys, a local browser, and the catalog versions of the depot") c = command(cs, "keys", cmd_keys, "the addressable keys of the catalog (the total on standard error)") c.add_argument("--prefix", default="", help="only the keys that start with this prefix") c.add_argument("--limit", type=int, default=200, help="at most N keys (default 200; 0: all)") c = command(cs, "browse", cmd_browse, "browse the configured regions' catalogs and bundles in a local web page") c.add_argument("--port", type=int, default=8000, help="port (default 8000)") c.add_argument("--host", default="127.0.0.1", help="address to listen on (default 127.0.0.1)") - command(cs, "versions", cmd_versions, "the catalog versions imported into the store (--json: nnnotes.catalogs/1)") + command(cs, "versions", cmd_versions, "the catalog versions imported into the depot (--json: nnnotes.catalogs/1)") c = command(cs, "import", cmd_import, "import a catalog file (with the APK's catalog when [paths] apk is set)") c.add_argument("file", help="catalog__.bin") c.add_argument("--label", help="label of the version (default: its resource version, else the sha256 prefix)") @@ -227,13 +229,13 @@ def register(sub) -> None: help="the game's resource version of this catalog (default: the JP source's version, else the " "version in the file name catalog__.bin)", ) - c = command(cs, "fetch", cmd_fetch, "fetch the region's current catalog and import it into the store") + c = command(cs, "fetch", cmd_fetch, "fetch the region's current catalog and import it into the depot") c.add_argument("--label", help="label of the version (default: the resource version when an API root is set)") c = command( cs, "diff", cmd_diff, - "keys, bundles, raw files (and objects) that differ between two catalog versions of the store " + "keys, bundles, raw files (and objects) that differ between two catalog versions of the depot " "(--json: nnnotes.catalog-diff/1)", ) c.add_argument("old", help="label, version id or sha256 prefix") diff --git a/src/nnnotes/cli/common.py b/src/nnnotes/cli/common.py index 40a786b..fe14e73 100644 --- a/src/nnnotes/cli/common.py +++ b/src/nnnotes/cli/common.py @@ -16,20 +16,19 @@ DIFFICULTY_CHOICES = ("easy", "normal", "hard", "expert") AUDIO_CHOICES = ("flac", "ogg", "wav") FLAC_LEVELS = range(13) -EMOJI_FONT = "emoji" # --font emoji=, as storysite.EMOJI_FONT +EMOJI_FONT = "emoji" # --font emoji= # command-line flags of settings: (section, key) -> (argparse dest, flag) FLAG_SETTINGS = { ("catalog", "region"): ("region", "--region"), ("catalog", "language"): ("language", "--language"), ("catalog", "version"): ("catalog_version", "--catalog-version"), ("paths", "catalog"): ("catalog", "--catalog"), - ("paths", "cache"): ("cache", "--cache"), ("paths", "master"): ("master", "--master"), ("paths", "apk"): ("apk", "--apk"), ("paths", "ffmpeg"): ("ffmpeg", "--ffmpeg"), ("paths", "vgmstream"): ("vgmstream", "--vgmstream"), ("paths", "node"): ("node", "--node"), - ("paths", "store"): ("store", "--store"), + ("paths", "depot"): ("depot", "--depot"), ("paths", "player"): ("player", "--player"), } # the global options: dest -> (flag, metavar, help); --player is an option of `site build` only @@ -41,15 +40,14 @@ "LANG", "catalog and client language: ja, en, zh-Hant, zh-Hans or ko ([catalog] language)", ), - "catalog": ("--catalog", "FILE", "catalog .bin file ([paths] catalog; else downloaded into the cache)"), + "catalog": ("--catalog", "FILE", "catalog .bin file ([paths] catalog; else downloaded into the depot)"), "catalog_version": ( "--catalog-version", "VERSION", "the resource version of an international catalog ([catalog] version; else the version the game API " "serves when an API root is configured, otherwise main)", ), - "cache": ("--cache", "DIR", "cache directory ([paths] cache)"), - "store": ("--store", "DIR", "store directory ([paths] store; default /store)"), + "depot": ("--depot", "DIR", "depot directory ([paths] depot)"), "master": ("--master", "DIR", "decoded master data directory ([paths] master)"), "apk": ("--apk", "FILE", "base.apk ([paths] apk)"), "ffmpeg": ("--ffmpeg", "EXE", "ffmpeg executable ([paths] ffmpeg; else on PATH)"), diff --git a/src/nnnotes/cli/depot.py b/src/nnnotes/cli/depot.py new file mode 100644 index 0000000..4814695 --- /dev/null +++ b/src/nnnotes/cli/depot.py @@ -0,0 +1,100 @@ +"""`nnnotes depot`: what `export assets` would run and why (plan), task descriptions run into the depot (run), the +check of every stored content and result (verify), and the removal of what no kept run reaches (gc).""" + +from __future__ import annotations + +import sys + +from ..source.config import usable_cpus +from ..source.session import Session +from . import assets +from .common import command, group, report, usage, workers_arg + + +def cmd_verify(args, cfg): + from ..pipeline.depot import open_depot + + workers = usable_cpus() if args.workers is None else args.workers + r = open_depot(cfg).verify(quick=args.quick, workers=workers) + report(args, r) + if r["problems"]: + sys.exit(assets.EXIT_FAILED) + + +def cmd_gc(args, cfg): + from ..core.downloads import exclusive + from ..pipeline.depot import open_depot + + if args.keep is not None and args.keep < 1: + usage(args, "--keep must be at least 1") + if args.grace < 0: + usage(args, "--grace must be nonnegative") + root = Session(cfg).depot_root() + with exclusive(root, args.lease) as alone: + if not alone: + sys.exit( + f"nnnotes depot gc: another command uses the depot ({root / 'leases'}); " + "gc runs when no other command does" + ) + depot = open_depot(cfg) + if args.remote and depot.remote is None: + usage(args, "--remote: the depot has no remote (setting depot.remote)") + r = depot.gc(keep=args.keep, dry_run=args.dry_run, remote=args.remote, grace=args.grace) + + def lines(r: dict, where: str = "") -> list[str]: + return [ + f"{where}{'would remove' if args.dry_run else 'removed'}: {r['runs']['removed']} runs, " + f"{r['results']['removed']} results, {r['contents']['removed']} contents " + f"({r['bytesRemoved'] / 1e6:.1f} MB)", + f"{where}kept: {r['runs']['kept']} runs, {r['results']['kept']} results, {r['contents']['kept']} contents", + ] + + report(args, r, lambda r: lines(r) + (lines(r["remote"], "remote ") if "remote" in r else [])) + + +def register(sub) -> None: + ss = group(sub, "depot", "the depot: its plan, single tasks, its check, its collection") + + c = command( + ss, + "plan", + assets.cmd_plan, + "what `export assets` would run and why, without running it (--json: nnnotes.plan/1)", + ) + c.add_argument("-o", "--out", help="output directory of the export (counts the layout files to write)") + assets.run_args(c) + c.add_argument("--since", metavar="RUN", help="explain against this run (default: the latest of the selection)") + c.add_argument("--check", action="store_true", help="exit 1 when anything would run or change") + c.add_argument("--census", action="store_true", help="first fetch and census the bundles without one") + c.add_argument("--emit-tasks", metavar="DIR", help="write the runnable tasks as task descriptions") + c.add_argument("--why", metavar="TASK", help="the reasons and key parts of one task") + + c = command(ss, "run", assets.cmd_run_stage, "run task descriptions (nnnotes.task/1) into the depot") + c.add_argument("tasks", nargs="+", metavar="TASK", help="task description files, or directories of them") + c.add_argument("--fetch", action="store_true", help="fetch inputs through their catalog locators") + c.add_argument("--force", action="store_true", help="run tasks whose result is stored (it must not change)") + + c = command(ss, "verify", cmd_verify, "check every stored content and result") + c.add_argument("--quick", action="store_true", help="check sizes only, do not hash the contents") + workers_arg(c, "parallel hashing", "the CPUs this process may use", zero="one at a time") + + c = command(ss, "gc", cmd_gc, "remove the runs, results and contents no kept run reaches") + c.add_argument( + "--keep", + type=int, + metavar="N", + help="keep the N latest runs of each selection (default: every run; the latest run of a selection is kept)", + ) + c.add_argument("--dry-run", action="store_true", help="count what would be removed, remove nothing") + c.add_argument( + "--grace", + type=float, + default=86400, + metavar="SECONDS", + help="keep recent results and their inputs and contents in both depots (default: 86400 seconds)", + ) + c.add_argument( + "--remote", + action="store_true", + help="also collect the depot's remote, by its runs (its results and contents younger than a day stay)", + ) diff --git a/src/nnnotes/cli/export.py b/src/nnnotes/cli/export.py index a9bbfb6..6c68660 100644 --- a/src/nnnotes/cli/export.py +++ b/src/nnnotes/cli/export.py @@ -1,14 +1,14 @@ -"""`nnnotes export`: the selected bundles of the catalog into the store and its layouts (`assets`), and single +"""`nnnotes export`: the selected bundles of the catalog into the depot and its layouts (`assets`), and single subjects into a directory or file: an ADV episode, a story, a live, a Live2D model, a spot, a room, shaders, a cue sheet, the APK's UI library.""" from __future__ import annotations -import os -import sys +import argparse from pathlib import Path -from ..core.jsonio import write_json +from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO +from ..source.config import ConfigError, use from ..source.session import Session from . import assets from .common import ( @@ -16,239 +16,427 @@ audio_args, band_args, command, + flac_level_arg, flac_options, - fonts_arg, - fonts_extra, group, known_keys, known_row, live_option_arg, live_options, + nonnegative, out_arg, + parse_font, + parse_languages, report, usage, ) def cmd_episode(args, cfg): - from ..content import adv + from ..pipeline.exportfiles import EpisodeStage md = Session(cfg).master_dir() known_row(args, md, "MasterAdv", args.adv_id, "episode") - cat = Session(cfg).catalog() - doc = adv.to_json(adv.extract(cat, md, args.adv_id)) - out = Path(args.out) - out.parent.mkdir(parents=True, exist_ok=True) - write_json(out, doc) - r = {"out": str(out), "commands": doc["commandCount"], "resources": len(doc["resources"])} + run, result = _file_product(args, cfg, EpisodeStage(), str(args.adv_id)) + doc = _product_document(run, result, "episode.json") + _place_files(run, result, args.out, single="episode.json") + r = {"out": str(args.out), "commands": doc["commandCount"], "resources": len(doc["resources"])} report(args, r, lambda r: [f"{r['out']} ({r['commands']} commands, {r['resources']} resources)"]) -def cmd_story(args, cfg): - from ..content import story - from ..site import webmodel - from ..source import languages - from ..unity.player import PlayerData - - fonts_extra(args.fonts) - flac = flac_options(args) - out = Path(os.path.abspath(args.out)) - models = Path(os.path.abspath(args.models or Path(args.out) / os.pardir / "live2d")) - try: - os.path.relpath(models, out) - except ValueError: - usage(args, f"--models {models}: not on the drive of {out} (story.json names it relative to the story)") - language = languages.check(cfg.require("catalog", "language")) # the story UI's language +def _checked_run(args, cfg, ws, prefix): + run, _, aborted = assets.run_request(args, cfg, ws, selection_prefix=[prefix]) + run.write() + if aborted or run.exit_code: + report(args, {"run": run.manifest()["id"], "failures": run.failures(), "aborted": aborted}) + raise SystemExit(assets.EXIT_ABORTED if aborted else assets.EXIT_FAILED) + return run + + +def _file_product(args, cfg, stage, subject): + from ..pipeline import contract + from ..pipeline.stages import Env + + use(cfg) + options = argparse.Namespace(**{**vars(args), "views": "none", "audio_format": getattr(args, "format", None)}) + ws = assets.Workspace(options, cfg, layouts=["files"]) + ws.run_index() + env = Env(ws.depot, {"master": ws.master(), "index": ws.index()}) + held = {loc["primaryKey"] for loc in ws.index()["locations"]} + ws.request({stage.name: [subject]}, sorted(f"key:{key}" for key in stage.keys(subject, env) if key in held)) + run = _checked_run(args, cfg, ws, stage.name) + tid = contract.task_id(stage.name, subject) + return run, run.depot.committed(run.tasks[tid]["key"], tid) + + +def _product_document(run, result, path): + from ..pipeline import contract + + artifact = next(a for a in result["artifacts"] if contract.parse_artifact_id(a["id"])[1] == path) + return contract.loads(run.depot.read(artifact["content"]["sha256"])) + + +def _place_files(run, result, directory, *, single=None): + from ..pipeline import contract, layout + from ..pipeline.depot import write_file + + entries = [] + for artifact in result["artifacts"]: + name = contract.parse_artifact_id(artifact["id"])[1] + if name == "objects" or single is not None and name != single: + continue + content = artifact["content"] + entries.append({"path": name, "id": artifact["id"], "sha256": content["sha256"], "size": content["size"]}) + out = Path(directory) + if single is not None: + if len(entries) != 1: + raise ValueError(f"export has no single {single} file") + entry = {**entries[0], "path": out.name} + write_file(out, run.depot.read(entry["sha256"])) + manifest = contract.layout_doc("files", {}, [entry]) + else: + manifest = layout.materialize( + out, "files", {}, entries, layout.from_depot(run.depot), link="copy", fail_fast=True + )["manifest"] + run.write({"files": run.depot.put(contract.encode(manifest))}) + + +def prepare_story(args, cfg): + from ..pipeline import contract + from ..pipeline import story as sp + from ..pipeline.stages import Env + + cfg.require_path("paths", "apk") # the ADV settings, the built-in graphics settings and CRI Lips are in the APK + use(cfg) # the tools the atoms name are part of the keys md = Session(cfg).master_dir() known_row(args, md, "MasterAdv", args.adv_id, "episode") - cat = Session(cfg).catalog() - player = PlayerData(Session(cfg).apk()) - source = webmodel.ModelDir(cat, player, models, force=args.force) - r = story.build( - cat, - md, - player, - args.adv_id, - out, - audio_format=args.format, - audio=not args.no_audio, - fonts=args.fonts, - models=source, - language=language, - audio_options=flac, - ) - report(args, {**r, "modelsBuilt": source.built, "modelsSkipped": source.skipped}) + ws = assets.Workspace(args, cfg, layouts=["product"]) + env = Env(ws.depot, {"master": md}) + subject = str(args.adv_id) + classes = {sp.STORY: sp.StoryStage, **sp.PART_STAGES} + ws.run_index() + held = {loc["primaryKey"] for loc in ws.index()["locations"]} + env.facts["index"] = ws.index() + from ..source.languages import check + + wanted_languages = getattr(args, "story_languages", None) or [check(cfg.get("catalog", "language"))] + if getattr(args, "fonts", "open") == "open": + supplied = dict(getattr(args, "font", None) or []) + for code in wanted_languages: + path = Path(supplied[code]) if code in supplied else cfg.path("paths.fonts", code) + if path is None or not path.is_file(): + raise ConfigError( + f"the story text of {code} needs a font file: --font {code}=PATH or [paths.fonts] {code}" + ) + + def request(products: dict) -> tuple[dict, list[str]]: + keys = {k for n, subjects in products.items() for s in subjects for k in classes[n]().keys(s, env)} + return products, sorted(f"key:{k}" for k in keys if k in held) + + def parts(run): # the products story.json names, with the story + tid = contract.task_id(sp.STORY, subject) + task = run.tasks.get(tid) + if task is None or task["status"] == "failed": + return None + doc = ws.depot.committed(task["key"], tid) + art = next((a for a in doc["artifacts"] if a["id"] == contract.artifact_id(tid, "story.json")), None) + if art is None: # the story is unsupported: the run reports it + return None + products = { + sp.STORY: [subject], + **sp.part_products( + contract.loads(ws.depot.read(art["content"]["sha256"])), + wanted_languages, + ), + } + episode = next(a for a in doc["artifacts"] if a["id"] == contract.artifact_id(tid, "episode.json"))["content"] + inputs = { + f"episode:{subject}": contract.Input( + f"episode:{subject}", + episode["sha256"], + episode["size"], + None, + ({"kind": "depot"},), + ) + } + env.facts["product_inputs"] = inputs + missing = sorted(k for n, subjects in products.items() if n != sp.STORY for s in subjects + for k in classes[n]().keys(s, env) if k not in held) # fmt: skip + if missing: + usage(args, f"episode {args.adv_id}: {', '.join(missing)} not in the catalog") + return (*request(products), inputs) + + ws.request(*request({sp.STORY: [subject]})) + return ws, parts -def cmd_live(args, cfg): - from ..content import live, liveoptions - from ..site.web import all_pairs - from ..source import languages - from ..unity.player import PlayerData - - fonts_extra(args.fonts) - request = live_options(args) - flac = flac_options(args) - language = languages.check(cfg.require("catalog", "language")) +def cmd_story(args, cfg): + ws, parts = prepare_story(args, cfg) + assets.export(args, cfg, ws, expand=parts) + + +def prepare_live(args, cfg): + from ..content import live, liveaudio, livenotes, liveoptions + from ..pipeline import live as lp + from ..pipeline.stages import Env + from ..source.languages import check + from ..source.master import table + + cfg.require_path("paths", "apk") # the built-in graphics and sound settings and the CRI ACF are in the APK + use(cfg) # the tools the atoms name are part of the keys md = Session(cfg).master_dir() known_row(args, md, "MasterLiveMusic", args.music_id, "music") - if (args.music_id, args.difficulty) not in all_pairs(md): - usage(args, f"music {args.music_id}: no {args.difficulty} chart (no MasterLiveMusicScore row)") try: - options = liveoptions.resolve(request, md) + options = liveoptions.resolve(live_options(args), md) except liveoptions.OptionSpecError as e: usage(args, str(e)) - cat = Session(cfg).catalog() - try: # an unknown --leader-card, a --band without its scene keys - live.resolve_band(cat, md, args.music_id, band=args.band, leader_card=args.leader_card) + band = args.band + if args.leader_card is not None: + tables = {n: table(md, n) for n in ("MasterMemberCard", "MasterCharacter", "MasterLiveMusic")} + try: + band = live.band_choice(tables, args.music_id, leader_card=args.leader_card)["band"] + except KeyError as e: + usage(args, e.args[0]) + ws = assets.Workspace(args, cfg, layouts=["product"]) + env = Env(ws.depot, {"master": md}) + stage, subject = lp.LiveStage(), live.live_subject(args.music_id, args.difficulty, band) + wanted_languages = getattr(args, "live_languages", None) or [check(cfg.get("catalog", "language"))] + try: # no chart of the difficulty, a music without a vocal character, a master table missing + b = stage.live_record(subject, env)["band"]["band"] + products = {lp.LIVE: [subject], **stage.parts(subject, env, wanted_languages)} except (KeyError, ValueError) as e: usage(args, e.args[0] if e.args else type(e).__name__) - r = live.build( - cat, - md, - PlayerData(Session(cfg).apk()), - args.music_id, - args.difficulty, - Path(args.out), - audio_format=args.format, - band=args.band, - leader_card=args.leader_card, - language=language, - fonts=args.fonts, - options=options, - **flac, - ) - report(args, r) + ws.request(products, []) + ws.params(ws.pipeline()) # the flags the stages take, checked before the index + ws.run_index() + env.facts["index"] = ws.index() + held = {loc["primaryKey"] for loc in ws.index()["locations"]} + missing = [k.format(band=b) for k in live.BAND_KEYS if k.format(band=b) not in held] + if missing: + usage(args, f"band {b}: {', '.join(missing)} not in the catalog") + if getattr(args, "fonts", "open") == "open": + supplied = dict(getattr(args, "font", None) or []) + for code in wanted_languages: + path = Path(supplied[code]) if code in supplied else cfg.path("paths.fonts", code) + if path is None or not path.is_file(): + raise ConfigError( + f"the live text of {code} needs a font file: --font {code}=PATH or [paths.fonts] {code}" + ) + skins = {r["_id"]: r["_assetName"] for r in table(md, "MasterLiveNoteSkin")} + effects = {r["_id"]: r["_assetName"] for r in table(md, "MasterLiveNoteEffectSkin")} + products[lp.NOTESKIN] += [skins[i] for i in options.designs if skins[i] not in products[lp.NOTESKIN]] + products[lp.NOTEEFFECT] += [ # a Light variant where the catalog has one, as the game + n for n, light in options.effect_sets(effects) if not light or livenotes.effect_settings_key(n) in held + ] + if options.se_patterns: + products[lp.SOUNDS].append(liveaudio.sounds_subject(options.se_patterns)) + classes = {lp.LIVE: lp.LiveStage, **lp.PART_STAGES} + stages = {n: classes[n]() for n in products} + keys = {k for n, subjects in products.items() for s in subjects for k in stages[n].keys(s, env)} + ws.request(products, sorted(f"key:{k}" for k in keys)) + return ws -def cmd_live2d(args, cfg): - from ..site import webmodel - from ..unity import live2d +def cmd_live(args, cfg): + assets.export(args, cfg, prepare_live(args, cfg)) - cfg.require_path("paths", "apk") # the Cubism component classes are read from the APK - cat = Session(cfg).catalog() + +def prepare_live2d(args, cfg): + from ..content import live2d + from ..pipeline.live2d import CUBISM, MODEL + + cfg.require_path("paths", "apk") # the Cubism component classes and the mask materials are in the APK + use(cfg) # the tools the atoms name are part of the keys + ws = assets.Workspace(args, cfg, layouts=["product" if args.player else "files"]) + ws.run_index() + keys = (loc["primaryKey"] for loc in ws.index()["locations"]) try: # a model id or a model key - (key,) = webmodel.catalog_models(cat, [args.key]).values() + (key,) = live2d.select_models([args.key], live2d.model_keys(keys)).values() except ValueError as e: usage(args, str(e)) - report(args, live2d.extract_model(cat, key, Path(args.out))) + ws.request({MODEL if args.player else CUBISM: [key]}, [f"key:{key}"]) + return ws + + +def cmd_live2d(args, cfg): + assets.export(args, cfg, prepare_live2d(args, cfg)) def cmd_spot(args, cfg): - from ..content import room, spot - from ..formats import shader + from ..pipeline.spot import ROOM, SPOT + from ..source.master import table cfg.require_path("paths", "apk") # the Spot / Spine component classes are read from the APK + use(cfg) # the tools the atoms name are part of the keys md = Session(cfg).master_dir() known_row(args, md, "MasterHomeSpot", args.spot_id, "spot") - cat = Session(cfg).catalog() - out = Path(args.out) - doc = spot.extract(cat, md, args.spot_id, out) - bg = doc["master"]["_backgroundAssetPath"] - rm = room.extract_room(cat, bg, out / "room.glb") - bundles = cat.fetch_key(bg) + cat.fetch_key(doc["master"]["_situationAssetPath"]) - s = shader.dump(bundles, out / "shaders") - r = { - "out": str(out), - "spot": {"tapTargets": len(doc["characters"]), "skeletons": len(doc["skeletons"])}, - "room": {"meshes": rm["meshCount"], "inactiveMeshes": rm["inactiveMeshes"]}, - "shaders": {"shaders": s["count"], "variants": s["variants"]}, - } - report( - args, - r, - lambda r: [ - f"{r['out']}/spot.json ({r['spot']['tapTargets']} tap targets, {r['spot']['skeletons']} skeletons)", - f"{r['out']}/room.glb ({r['room']['meshes']} meshes, {r['room']['inactiveMeshes']} inactive)", - f"{r['out']}/shaders/ ({r['shaders']['shaders']} shaders, {r['shaders']['variants']} variants)", - ], - ) + (row,) = [r for r in table(md, "MasterHomeSpot") if r.get("_id") == args.spot_id] + situation, background = row["_situationAssetPath"], row["_backgroundAssetPath"] + ws = assets.Workspace(args, cfg, layouts=["files"]) + ws.request({SPOT: [situation], ROOM: [background]}, [f"key:{situation}", f"key:{background}"]) + assets.export(args, cfg, ws) def cmd_room(args, cfg): from ..content import room + from ..pipeline.spot import ROOM - cat = Session(cfg).catalog() - known_keys(args, cat, [args.key]) - r = room.extract_room(cat, args.key, Path(args.out)) - report(args, r, lambda r: [f"{r['glb']} ({r['meshCount']} meshes, {r['inactiveMeshes']} inactive)"]) + try: + room.room_name(args.key) + except ValueError as e: + usage(args, str(e)) + use(cfg) + ws = assets.Workspace(args, cfg, layouts=["files"]) + ws.run_index() + if not any(loc["primaryKey"] == args.key for loc in ws.index()["locations"]): + usage(args, f"{args.key}: not a key of the catalog") + ws.request({ROOM: [args.key]}, [f"key:{args.key}"]) + assets.export(args, cfg, ws) def cmd_shader(args, cfg): - from ..formats import shader + from ..pipeline import contract + from ..pipeline.census import CensusStage + from ..pipeline.contract import Input, Task + from ..pipeline.depot import open_depot + from ..pipeline.exportfiles import ShaderFilesStage + from ..pipeline.link import ScriptsStage + from ..pipeline.objexport import ExportStage + from ..pipeline.orchestrate import Orchestrator + from ..pipeline.stages import Stage, execute cat = Session(cfg).catalog() if args.key: known_keys(args, cat, [args.key]) - bundles = cat.fetch_key(args.key) + paths = cat.fetch_key(args.key) else: if cat.apk is None: cfg.require_path("paths", "apk") - bundles = [] + paths = [] for s in args.apk_bundle: try: - bundles.append(cat.apk_bundle(s)) + paths.append(cat.apk_bundle(s)) except KeyError as e: # none or several bundles match usage(args, f"--apk-bundle {e.args[0]}") - r = shader.dump(bundles, Path(args.out)) + use(cfg) + depot, bundles, names = open_depot(cfg), {}, {} + for path in sorted(set(paths)): + subject = contract.stable_bundle_name(path.name) + sha, size = depot.identify(path) + source = Input("bundle", sha, size, path.name, ({"kind": "file", "path": str(path.resolve())},)) + if subject in bundles and bundles[subject].sha256 != sha: + usage(args, f"multiple bundle versions have the stable name {subject}") + bundles[subject], names[subject] = source, path.name + stages: dict[str, Stage] = {s.name: s for s in (CensusStage(), ScriptsStage(), ExportStage())} + run = Orchestrator(depot, stages, workers=0).run( + list(stages), + facts={"bundles": bundles, "catalogs": {}, "selected": sorted(bundles), "classes": ["Shader"]}, + selection=["export.shader", *sorted(bundles)], + ) + run.write() + if run.exit_code: + report(args, {"run": run.manifest()["id"], "failures": run.failures()}) + raise SystemExit(assets.EXIT_FAILED) + records = [] + for tid, result in run.results(): + stage, subject = contract.parse_task_id(tid) + if stage == "unity.export": + bundle = subject.split(":", 1)[0] + records.append( + { + "source": names.get(bundle, bundle), + "items": result["items"], + "artifacts": {a["id"]: a for a in result["artifacts"]}, + } + ) + data, stage = contract.encode(records), ShaderFilesStage() + task = Task( + stage.name, + stage.version, + "selected", + inputs=(Input("records", depot.put(data), len(data), None, ({"kind": "depot"},)),), + ) + executed = execute(task, depot, {stage.name: stage}) + run.tasks[task.id] = {"id": task.id, "key": task.key, "status": executed.status} + run.stages[stage.name] = stage.version + result = depot.committed(task.key, task.id) + run.write() + _place_files(run, result, args.out) + index = _product_document(run, result, "shaders.json") + r = { + "count": len(index), + "names": sorted(row["name"] for row in index), + "variants": sum(len(row["variants"]) for row in index), + } report(args, r, lambda r: [f"{r['count']} shaders, {r['variants']} variants", *(f" {n}" for n in r["names"])]) def cmd_audio(args, cfg): - from ..formats import cri + from ..pipeline.exportfiles import AudioFilesStage - flac = flac_options(args) + flac_options(args) cfg.require_path("paths", "apk") # the HCA keycode is read from the APK cat = Session(cfg).catalog() if not cat.has(f"Cri/Sound/{args.cue_sheet}"): usage(args, f"cue sheet {args.cue_sheet}: no key Cri/Sound/{args.cue_sheet} in the catalog") - out = Path(args.out) - cues = cri.decode(cat, args.cue_sheet, out, fmt=args.format, **flac) - report(args, {"out": str(out), "cues": len(cues)}, lambda r: [f"{r['out']} ({r['cues']} cues)"]) + run, result = _file_product(args, cfg, AudioFilesStage(), args.cue_sheet) + cues = _product_document(run, result, "cues.json") + _place_files(run, result, args.out) + report(args, {"out": str(args.out), "cues": len(cues)}, lambda r: [f"{r['out']} ({r['cues']} cues)"]) def cmd_ui(args, cfg): - from ..content import ui + from ..pipeline.ui import UI if args.limit is not None and args.limit < 1: usage(args, "--limit must be positive") - index = ui.export_apk_library( - cfg, - args.out, - keys=args.key, - prefix=args.prefix, - limit=args.limit, - dependencies=not args.no_dependencies, - force=args.force, - ) - report( - args, - { - "index": str(Path(args.out).resolve() / "index.json"), - "counts": index["counts"], - "embedded": len(index["embedded"]), - "controllers": len(index["controllers"]), - }, + cfg.require_path("paths", "apk") + use(cfg) + ws = assets.Workspace(args, cfg, layouts=["product"]) + ws.run_index() + known = { + loc["primaryKey"] for loc in ws.index()["locations"] if loc["id"].startswith("apk:") and loc["kind"] == "asset" + } + keys = sorted(set(args.key) if args.key else {key for key in known if key.startswith(args.prefix)}) + if missing := sorted(set(keys) - known): + usage(args, "UI key not in the APK catalog: " + ", ".join(missing)) + if args.limit is not None: + keys = keys[: args.limit] + if not keys: + usage(args, "no UI keys selected") + ws.request({UI: keys}, [f"key:{key}" for key in keys]) + assets.export(args, cfg, ws) + + +def product_args(c) -> None: + """The options of a command that exports products through the depot (as export assets).""" + c.add_argument( + "--stored-catalog", + metavar="LABEL|SHA", + help="a catalog version of the depot (`catalog versions`; default: the current catalog)", ) - if index["counts"].get("failed"): - sys.exit(1) + c.add_argument("--workers", type=nonnegative, metavar="N", help="worker processes (default: the usable CPUs)") + c.add_argument("--link", choices=("auto", "clone", "hard", "copy"), help="as export assets --link") + c.add_argument("--explain", action="store_true", help="print every setting of the run first") + c.add_argument("--dry-run", action="store_true", help="print the plan, run nothing") + c.set_defaults(views="none", strict=False) def register(sub) -> None: - es = group(sub, "export", "game data -> common formats: the store's asset export and single subjects") + es = group(sub, "export", "game data -> common formats: the depot's asset export and single subjects") c = command( es, "assets", assets.cmd_export, - "every object of the selected bundles -> common formats (the store and its layouts)", + "every object of the selected bundles -> common formats (the depot and its layouts)", ) out_arg(c, "output directory") assets.run_args(c) c.add_argument( "--link", choices=("auto", "clone", "hard", "copy"), - help="how the layout files are made from the store: auto (default; [export] link): a clone where " + help="how the layout files are made from the depot: auto (default; [export] link): a clone where " "the file system makes them, else a hard link (read-only), else a copy; or that method only", ) c.add_argument("--strict", action="store_true", help="exit 1 when a view has gaps") @@ -262,48 +450,105 @@ def register(sub) -> None: es, "story", cmd_story, - "an ADV episode -> story directory (episode, audio, scene, UI, media, videos) + its Live2D models", + "an ADV episode -> the story product and the products it plays with (scene, assets, Live2D models, " + "cue sheets, videos, chat, UI, fonts, CRI Lips)", ) c.add_argument("adv_id", type=int, help="MasterAdv id of the episode") - out_arg(c, "output directory") c.add_argument( - "--models", metavar="DIR", help="directory of the Live2D models, one / each (default: OUT/../live2d)" + "--story-languages", + type=parse_languages, + metavar="LANG,...", + help="text languages, comma-separated (default: [catalog] language)", ) - c.add_argument("--force", action="store_true", help="export the models again whose directory exists") - audio_args(c) - c.add_argument("--no-audio", action="store_true", help="do not decode the cue sheets") - fonts_arg( - c, - "open (default): text layout and style only, no font data; game: also the game's TMP fonts " - "(needs the 'fonts' extra)", + c.add_argument( + "--fonts", + choices=("open", "game"), + default="open", + help="glyph source: open font files (default) or game TMP assets", + ) + c.add_argument( + "--font", + type=parse_font, + action="append", + metavar="LANG=PATH", + help="font file for a language, or emoji=PATH (repeatable; [paths] fonts.)", + ) + c.add_argument( + "--format", + dest="audio_format", + metavar="FORMAT", + default=DEFAULT_AUDIO_FORMAT, + choices=sorted(WEB_AUDIO), + help=f"web audio format of the cue sheets (default {DEFAULT_AUDIO_FORMAT})", ) + flac_level_arg(c, "of the decoded sounds") + out_arg(c, "output directory") + product_args(c) + c.set_defaults(movie_format="webm") # the story videos: cri.movie's movie.webm - c = command(es, "live", cmd_live, "a live (music + difficulty) -> self-contained live directory") + c = command( + es, + "live", + cmd_live, + "a live (music + difficulty) -> the live product and the products it plays with (scene, note highway, " + "sounds, music)", + ) c.add_argument("music_id", type=int, help="MasterLiveMusic id") c.add_argument( "--difficulty", default="expert", choices=DIFFICULTY_CHOICES, help="chart difficulty (default expert)" ) - audio_args(c) - fonts_arg( - c, - "start canvas text: open (default): layout and style only, no font data (your fonts are not used); " - "game: also the game's TMP fonts (needs the 'fonts' extra)", + c.add_argument( + "--format", + dest="audio_format", + metavar="FORMAT", + default=DEFAULT_AUDIO_FORMAT, + choices=sorted(WEB_AUDIO), + help=f"web audio format of the BGM (default {DEFAULT_AUDIO_FORMAT})", ) + flac_level_arg(c, "of the decoded sounds") band_args(c) live_option_arg(c) + c.add_argument( + "--live-languages", + type=parse_languages, + metavar="CODES", + help="comma-separated UI languages, or all (default: catalog language)", + ) + c.add_argument( + "--fonts", + choices=("open", "game"), + default="open", + help="glyph source: open font files (default) or game TMP assets", + ) + c.add_argument( + "--font", + type=parse_font, + action="append", + metavar="LANG=PATH", + help="font file for a language (repeatable; [paths] fonts.)", + ) out_arg(c, "output directory") + product_args(c) - c = command(es, "live2d", cmd_live2d, "a Live2D model -> runtime model directory") + c = command(es, "live2d", cmd_live2d, "a Live2D model -> its Cubism SDK files, or the player's model") c.add_argument("key", help="model key Character/Live2D///model/, or its model id ") out_arg(c, "output directory") + c.add_argument( + "--player", + action="store_true", + help="write the player's model (models/.json and assets/) instead of the Cubism SDK files", + ) + product_args(c) - c = command(es, "spot", cmd_spot, "a spot -> spot.json + Spine + room.glb + shaders") + c = command(es, "spot", cmd_spot, "a spot -> spot.json + Spine files + room.glb + room.json") c.add_argument("spot_id", type=int, help="MasterHomeSpot id") out_arg(c, "output directory") + product_args(c) - c = command(es, "room", cmd_room, "a background prefab -> glb") - c.add_argument("key", help="addressable key of the prefab") - out_arg(c, "output .glb file") + c = command(es, "room", cmd_room, "a spot background -> room.glb + room.json") + c.add_argument("key", help="background key Spot//Background/") + out_arg(c, "output directory") + product_args(c) c = command(es, "shader", cmd_shader, "the shaders of a key's bundle closure or of APK bundles") g = c.add_mutually_exclusive_group(required=True) @@ -321,5 +566,4 @@ def register(sub) -> None: c.add_argument("--key", action="append", help="exact APK catalog key (repeatable)") c.add_argument("--prefix", default="EmbUI/", help="APK UI key prefix (default EmbUI/)") c.add_argument("--limit", type=int, help="maximum number of keys") - c.add_argument("--no-dependencies", action="store_true", help="omit additional prefab roots and controllers") - c.add_argument("--force", action="store_true", help="re-export the selected keys") + product_args(c) diff --git a/src/nnnotes/cli/musicdata.py b/src/nnnotes/cli/musicdata.py index dab1236..c39d7e7 100644 --- a/src/nnnotes/cli/musicdata.py +++ b/src/nnnotes/cli/musicdata.py @@ -41,7 +41,7 @@ def cmd_music_data(args, cfg): None if args.no_bgm else musicdata.catalog_bgm(cat), region=region, client=deckdata.apk_client(apk) if apk is not None else {}, - catalog=deckdata.catalog_info(cat, session.store_root()), + catalog=deckdata.catalog_info(cat, session.depot_root()), deck=deck, full=args.full, jacket=musicdata.catalog_jacket(cat) if args.jackets else None, diff --git a/src/nnnotes/cli/site.py b/src/nnnotes/cli/site.py index 6b9629a..a49a6a3 100644 --- a/src/nnnotes/cli/site.py +++ b/src/nnnotes/cli/site.py @@ -1,14 +1,20 @@ -"""`nnnotes site build`: live charts, Live2D models and stories -> a static site for ournotes-player.""" +"""Build a named static site from depot products and publish its complete layout.""" from __future__ import annotations -import sys +import argparse +import re from pathlib import Path -from ..site.compress import DEFAULT_ENCODING, ENCODINGS -from ..site.webaudio import DEFAULT_AUDIO_FORMAT, WEB_AUDIO +from ..content import sitefacts +from ..formats.compression import DEFAULT_ENCODING, ENCODINGS +from ..formats.encoders import DEFAULT_AUDIO_FORMAT, WEB_AUDIO +from ..pipeline import contract +from ..pipeline.depot import open_depot from ..source.config import ConfigError, use +from ..source.languages import check from ..source.session import Session +from . import assets, export from .common import ( band_args, command, @@ -26,138 +32,203 @@ ) +def product_entries(args, cfg, ws, expand=None): + run, _, aborted = assets.run_request(args, cfg, ws, expand=expand, selection_prefix=["site-product:" + args.name]) + saved = run.write() + if aborted or run.exit_code: + report(args, {"run": saved["id"], "tasks": saved["summary"], "failures": run.failures(), "aborted": aborted}) + raise SystemExit(assets.EXIT_ABORTED if aborted else assets.EXIT_FAILED) + done = {tid: t["key"] for tid, t in run.tasks.items() if t["status"] != "failed"} + ws.done(done) + output = {tid for tid in done if ws._output(contract.parse_task_id(tid)[0])} + placed = ws.layout_input({tid: done[tid] for tid in output}) + for tid, doc in run.results(): + if tid in output: + placed.add(tid, doc) + entries, _ = ws.layout_entries("product", placed) + return entries, saved["summary"] + + def cmd_build(args, cfg): - from ..content import liveoptions - from ..site import sitefiles, siteindex, storysite, web, webmodel + from ..content import live2d, liveoptions + from ..pipeline import live as lp + from ..pipeline import story as sp + from ..site import catalog, pages + from ..site.build import Builder, document, documents, model_stats, object_document, size + from ..source.master import table - request = live_options(args) - charts, models = bool(args.pair or args.all), bool(args.live2d or args.all_live2d) + charts = bool(args.pair or args.all) + models = bool(args.live2d or args.all_live2d) stories = bool(args.story or args.all_stories) - if not (charts or models or stories or args.player_only or args.reingest_json): - usage( - args, - "one of the arguments --pair --all --live2d --all-live2d --story --all-stories --player-only " - "--reingest-json is required", - ) - if models and (args.player_only or args.reingest_json): - usage(args, "--player-only and --reingest-json build nothing: leave out --live2d / --all-live2d") - if stories and (args.player_only or args.reingest_json): - usage(args, "--player-only and --reingest-json build nothing: leave out --story / --all-stories") - if request and not charts: - usage(args, "--live-option applies to charts: give --pair or --all") - player = cfg.path("paths", "player") - out = Path(args.out) - if args.player_only or args.reingest_json: - r = { - **(sitefiles.reingest_json(out, args.compress) if args.reingest_json else {}), - **sitefiles.write_player(out, sitefiles.check_player(player)), - **siteindex.write_index(out), - } - else: - sitefiles.check_player(player) - regions = None # the one [catalog] region - if args.site_regions or args.all_regions: - regions = sitefiles.site_regions(cfg, args.site_regions, args.all_regions) - if (stories or models) and regions and len({cfg.provider(r) for r in regions}) > 1: - raise ConfigError("build JP stories/models in a separate site directory from international releases") - if (stories or models) and regions: - cfg = use(cfg.for_region(regions[0])) - base = {"region": regions[0]} if regions else {} - unknown = web.unknown_pairs(cfg, args.pair, regions) if args.pair else [] - if unknown: - usage( - args, - f"chart{'s' if len(unknown) > 1 else ''} {', '.join(f'{m}:{d}' for m, d in unknown)}: no " - f"MasterLiveMusicScore row in the master data of the site's region(s)", - ) - if request: # a value a region's master data does not have - try: - for md in sitefiles.region_masters(cfg, sitefiles.site_regions(cfg, regions)).values(): - liveoptions.resolve(request, md) - except liveoptions.OptionSpecError as e: - usage(args, str(e)) - r = {} - encoding = args.compress - if models: - cfg.require_path("paths", "apk") # the Cubism component classes and mask materials: the APK - try: - selected = webmodel.catalog_models(Session(cfg).catalog(bundles=False, **base), args.live2d) - except ValueError as e: - usage(args, str(e)) - r.update( - webmodel.build( - out, - selected, - cfg, - player, - force=args.force, - tmp_dir=args.tmp, - workers=args.workers, - encoding=encoding, - **base, - ) - ) - if charts: - fonts_extra(args.fonts) - r.update( - web.build( - out, - None if args.all else args.pair, - cfg, - player, - args.format, - audio=not args.no_audio, - force=args.force, - tmp_dir=args.tmp, - workers=args.workers, - band=args.band, - leader_card=args.leader_card, - regions=regions, - fonts=args.fonts, - read_workers=args.read_workers, - live_options=request, - encoding=args.compress, - ) - ) - if stories: - from ..unity.tmpfont import require_extra + if not (charts or models or stories or args.player_only): + usage(args, "one of --pair --all --live2d --all-live2d --story --all-stories --player-only is required") + if args.player_only and (models or stories): + usage(args, "--player-only cannot be combined with a product selection") + if not re.fullmatch(r"[a-zA-Z0-9_-]+", args.name): + usage(args, "--name must contain letters, digits, underscores or hyphens") + requested_options = live_options(args) + if requested_options and not charts: + usage(args, "--live-option requires --pair or --all") + player = pages.check_player(cfg.path("paths", "player")) + depot = open_depot(cfg) + if args.player_only: + saved, _ = catalog.load(depot, args.name) + if saved is None: + usage(args, f"site {args.name}: no saved catalog; select products for the first build") + builder = Builder(depot, args.name, saved["language"], saved["regions"], player) + report(args, builder.finish(Path(args.out))) + return + regions = pages.site_regions(cfg, args.site_regions, args.all_regions) + if (stories or models) and len({cfg.provider(r) for r in regions}) > 1: + raise ConfigError("build JP stories/models in a separate site from international releases") + language = check(cfg.get("catalog", "language")) + builder = Builder(depot, args.name, language, pages.region_meta(cfg, regions), player, encoding=args.compress) + for region in regions: + cfg.for_region(region).require_path("paths", "apk") + if charts: + fonts_extra(args.fonts) + if stories: + from ..unity.tmpfont import require_extra - require_extra("--story / --all-stories") # the font assets of the story text (open and game) - cfg.require_path("paths", "apk") # the ADV settings and UI are embedded content of the APK - unknown = storysite.unknown_stories(cfg, args.story, regions) if args.story else [] - if unknown: - noun = "stories" if len(unknown) > 1 else "story" - usage( - args, - f"{noun} {', '.join(map(str, unknown))}: no MasterAdv row in the master data of the " - f"site's region(s)", - ) - r.update( - storysite.build( - out, - args.story, - cfg, - player, - args.format, - audio=not args.no_audio, - force=args.force, - tmp_dir=args.tmp, - workers=args.workers, - regions=regions, - story_languages=args.story_languages, - fonts=args.fonts, - fonts_flags=dict(args.font or []), - encoding=encoding, - ) - ) - report(args, r) - if ( - r.get("failed") - or r.get("modelsFailed") - or r.get("storiesFailed") - or (r.get("storyModels") or {}).get("modelsFailed") - ): - sys.exit(1) + require_extra("--story / --all-stories") + masters = ( + pages.region_masters(cfg, regions) + if charts or stories + else {r: Session(cfg).master_dir(r) if cfg.path(*cfg.master(r)) is not None else None for r in regions} + ) + pairs = {r: set(sitefacts.all_pairs(md)) for r, md in masters.items()} if charts else {} + episodes = ( + {r: {row["_id"] for row in table(md, "MasterAdv")} for r, md in masters.items() if md is not None} + if stories + else {} + ) + if args.pair and (missing := set(args.pair) - set.union(*pairs.values())): + usage(args, "charts not in the site's master data: " + ", ".join(f"{m}:{d}" for m, d in sorted(missing))) + if args.story and (missing := set(args.story) - set.union(*episodes.values())): + usage(args, "stories not in the site's master data: " + ", ".join(map(str, sorted(missing)))) + summaries = [] + try: + for region in regions: + regional = use(cfg.for_region(region)) + md = masters[region] + lang = check(regional.get("catalog", "language") or language) + wanted = args.story_languages or [lang] + base = { + **vars(args), + "audio_format": args.format, + "movie_format": "webm", + "views": "none", + "live_languages": [lang], + "story_languages": wanted, + } + if charts: + options = liveoptions.resolve(requested_options, md) + selected = pairs[region] if args.all else set(args.pair) & pairs[region] + if args.all: + builder.replace.add((region, "live")) + for music, difficulty in sorted(selected): + request = argparse.Namespace(**{**base, "music_id": music, "difficulty": difficulty}) + ws = export.prepare_live(request, regional) + entries, summary = product_entries(request, regional, ws) + summaries.append(summary) + assert ws.products is not None + subject = ws.products[lp.LIVE][0] + source = lp.LiveStage().manifest(subject) + manifests = documents(builder.depot, entries) + live = document(builder.depot, manifests, source, "live.json") + music_doc = document(builder.depot, manifests, live["parts"]["music"], "music.json") + facts = { + **sitefacts.chart(live, music_doc, lang), + "audio": not args.no_audio, + "audioFormat": None if args.no_audio else args.format, + "flows": ["direct"], + "quality": 1, + } + snapshot = builder.add( + entries, + source, + "live", + f"{music}_{difficulty}", + region, + facts, + languages=[lang], + options=options, + audio=not args.no_audio, + ) + facts["bytes"] = size(snapshot, source) + if models: + ws = assets.Workspace(argparse.Namespace(**base), regional, layouts=["product"]) + ws.run_index() + keys = live2d.model_keys(loc["primaryKey"] for loc in ws.index()["locations"]) + try: + selected = live2d.select_models(args.live2d, keys) + except ValueError as error: + usage(args, str(error)) + names = sitefacts.optional_model_names(md, lang) + if args.all_live2d: + builder.replace.add((region, "model")) + for mid, key in sorted(selected.items()): + request = argparse.Namespace(**{**base, "key": key, "player": True}) + model_ws = export.prepare_live2d(request, regional) + entries, summary = product_entries(request, regional, model_ws) + summaries.append(summary) + source = f"models/{mid}.json" + facts = {"key": key, **names.get(key, {})} + snapshot = builder.add(entries, source, "model", mid, region, facts) + facts.update(model_stats(snapshot, source)) + if stories: + assert md is not None + groups = sitefacts.story_groups(md) + selected = episodes[region] if args.all_stories else set(args.story) & episodes[region] + if args.all_stories: + builder.replace.add((region, "story")) + for adv_id in sorted(selected): + request = argparse.Namespace(**{**base, "adv_id": adv_id}) + ws, expand = export.prepare_story(request, regional) + entries, summary = product_entries(request, regional, ws, expand) + summaries.append(summary) + source = sp.StoryStage().manifest(str(adv_id)) + manifests = documents(builder.depot, entries) + episode = document(builder.depot, manifests, source, "episode.json") + story_doc = document(builder.depot, manifests, source, "story.json") + scene_path = story_doc["parts"]["scene"] + scene = document(builder.depot, manifests, scene_path, "scene.json") + settings = object_document( + builder.depot, manifests, scene_path, scene["settings"]["playerSettings"] + ) + facts = { + **sitefacts.story(episode, groups.get(adv_id, []), wanted, lang, settings), + "audio": not args.no_audio, + "audioFormat": args.format, + "fonts": args.fonts, + } + snapshot = builder.add( + entries, source, "story", adv_id, region, facts, languages=wanted, audio=not args.no_audio + ) + facts["size"] = { + "common": 0, + "models": 0, + "languages": {code: size(snapshot, source, code) for code in wanted}, + } + names = sitefacts.optional_model_names(md, lang) + for path, model in manifests.items(): + if model["product"] != "live2d.model" or path not in snapshot.paths: + continue + key = model["subject"] + builder.topics.append( + { + "kind": "model", + "id": live2d.model_id(key), + "region": region, + "manifest": snapshot.paths[path], + "entry": {"key": key, **names.get(key, {}), **model_stats(snapshot, path)}, + } + ) + result = builder.finish(Path(args.out)) + result["products"] = {key: sum(s[key] for s in summaries) for key in ["tasks", "hit", "ran", "failed"]} + report(args, result) + finally: + use(cfg) def register(sub) -> None: @@ -170,14 +241,12 @@ def register(sub) -> None: "per-model / per-story data)", ) c.add_argument("-o", "--out", required=True, metavar="SITE", help="site directory") + c.add_argument("--name", default="default", help="persistent site name in the depot (default: default)") c.add_argument("--player", help="ournotes-player checkout (built) or installed package ([paths] player)") g = c.add_mutually_exclusive_group() g.add_argument("--pair", type=parse_pair, action="append", help=": (repeatable)") g.add_argument("--all", action="store_true", help="every MasterLiveMusic x difficulty") - g.add_argument( - "--player-only", action="store_true", help="rewrite the player files, charts.json and models.json only" - ) - g.add_argument("--reingest-json", action="store_true", help="store every chart's and model's JSON files again") + g.add_argument("--player-only", action="store_true", help="publish the saved catalog with current player pages") m = c.add_mutually_exclusive_group() m.add_argument( "--live2d", @@ -200,25 +269,10 @@ def register(sub) -> None: help="encoding of the compressible assets (JSON, shaders, moc3, ...) where it makes them " "smaller: gzip (default), br (brotli) or none", ) - c.add_argument( - "--force", - action="store_true", - help="rebuild charts, models and stories whose manifest exists (stories: also their models)", - ) - c.add_argument("--tmp", help="directory for the temporary live and model builds (default .tmp)") - c.add_argument( - "--workers", - type=nonnegative, - metavar="N", - help="parallel music / story / model processes (default a quarter of the CPUs, up to 8; models up to 4; " - "0: this process)", - ) - c.add_argument( - "--read-workers", - type=positive, - metavar="N", - help="chart read sets run at a time (default half the CPUs, up to 16)", - ) + c.add_argument("--workers", type=nonnegative, metavar="N", help="product task worker processes; 0: this process") + c.add_argument("--memory", type=assets._gib, metavar="GiB", help="product task memory budget") + c.add_argument("--fetch-workers", type=positive, metavar="N", help="parallel source downloads") + c.add_argument("--stored-catalog", metavar="LABEL|SHA", help="a stored catalog version of the depot") t = c.add_mutually_exclusive_group() t.add_argument( "--story", type=int, action="append", metavar="ADV_ID", help="story episode: its MasterAdv id (repeatable)" @@ -228,7 +282,7 @@ def register(sub) -> None: "--story-languages", type=parse_languages, metavar="LANGS", - help="languages of the stories' text, comma-separated (default: ja,en,zh-Hant,zh-Hans,ko)", + help="languages of the stories' text, comma-separated (default: the catalog language)", ) c.add_argument( "--font", @@ -252,9 +306,8 @@ def register(sub) -> None: "--fonts", default="open", choices=("open", "game"), - help="text of the start canvas and the stories: open (default): the start canvas has layout and " - "style only, the stories glyphs from your font files (--font); game: the game's TMP fonts " - "(needs the 'fonts' extra)", + help="text of the start canvas and stories: open (default): glyphs from your font files (--font); " + "game: the game's TMP fonts (needs the 'fonts' extra)", ) band_args(c) live_option_arg(c) diff --git a/src/nnnotes/cli/store.py b/src/nnnotes/cli/store.py deleted file mode 100644 index 4736a4f..0000000 --- a/src/nnnotes/cli/store.py +++ /dev/null @@ -1,48 +0,0 @@ -"""`nnnotes store`: what `export assets` would run and why (plan), task descriptions run into the store (run), and the -check of every stored content and result (verify).""" - -from __future__ import annotations - -import sys - -from ..source.config import usable_cpus -from ..source.session import Session -from . import assets -from .common import command, group, report, workers_arg - - -def cmd_verify(args, cfg): - from ..pipeline.store import Store - - workers = usable_cpus() if args.workers is None else args.workers - r = Store(Session(cfg).store_root()).verify(quick=args.quick, workers=workers) - report(args, r) - if r["problems"]: - sys.exit(assets.EXIT_FAILED) - - -def register(sub) -> None: - ss = group(sub, "store", "the store of `export assets`: its plan, single tasks, its check") - - c = command( - ss, - "plan", - assets.cmd_plan, - "what `export assets` would run and why, without running it (--json: nnnotes.plan/1)", - ) - c.add_argument("-o", "--out", help="output directory of the export (counts the layout files to write)") - assets.run_args(c) - c.add_argument("--since", metavar="RUN", help="explain against this run (default: the latest of the selection)") - c.add_argument("--check", action="store_true", help="exit 1 when anything would run or change") - c.add_argument("--census", action="store_true", help="first fetch and census the bundles without one") - c.add_argument("--emit-tasks", metavar="DIR", help="write the runnable tasks as task descriptions") - c.add_argument("--why", metavar="TASK", help="the reasons and key parts of one task") - - c = command(ss, "run", assets.cmd_run_stage, "run task descriptions (nnnotes.task/1) into the store") - c.add_argument("tasks", nargs="+", metavar="TASK", help="task description files, or directories of them") - c.add_argument("--fetch", action="store_true", help="fetch inputs through their catalog locators") - c.add_argument("--force", action="store_true", help="run tasks whose result is stored (it must not change)") - - c = command(ss, "verify", cmd_verify, "check every stored content and result") - c.add_argument("--quick", action="store_true", help="check sizes only, do not hash the contents") - workers_arg(c, "parallel hashing", "the CPUs this process may use", zero="one at a time") diff --git a/src/nnnotes/content/adv.py b/src/nnnotes/content/adv.py index 8dc863e..fdfa074 100644 --- a/src/nnnotes/content/adv.py +++ b/src/nnnotes/content/adv.py @@ -11,13 +11,13 @@ from __future__ import annotations +import json from dataclasses import dataclass from pathlib import Path from ..source import master from ..source.catalog import Catalog from ..source.config import apk_missing -from ..unity import reader as unity from .advcommand import COMMAND # AdvEpisodeResourceLoader.AddEpisodeLoadingTask, per command: (resource kind, catalog-key prefix) of the asset named @@ -45,6 +45,7 @@ TRANSITION_COMMANDS = ("FadeOut", "FadeIn") VIDEO_COMMANDS = ("Movie", "Clip") CHAT_ICON_COMMANDS = ("ChatTalk", "ChatStamp") +SHARD_SUFFIXES = ("-Text", "-Sound", "-SoundCueSheet", "-Video") # sibling keys of the episode key LANGS = ("_japanese", "_english", "_traditionalChinese", "_simplifiedChinese", "_korean") @@ -77,7 +78,23 @@ def _master_text(text_id: str, master_dir: Path) -> dict: raise KeyError(f"MasterText has no row {text_id}") +def parse_shard(text: str) -> dict: + """A `-Text/-Sound/...` shard: {_header:[{_name,_type}], _allData:[rows]}. + + Returns {"header": [...], "rows": [...]}. Row keys keep their leading + underscore + lowercased-first-letter form as serialized (e.g. `_cueName`). + """ + d = json.loads(text) + return {"header": d.get("_header", []), "rows": d.get("_allData", [])} + + +def shard_index(rows: list[dict], id_key: str = "_id") -> dict: + return {r[id_key]: r for r in rows if id_key in r} + + def _text_of(cat: Catalog, key: str) -> str | None: + from ..unity import reader as unity + if not cat.has(key): return None for p in cat.fetch_key(key): @@ -88,6 +105,8 @@ def _text_of(cat: Catalog, key: str) -> str | None: def extract(cat: Catalog, master_dir: Path, adv_id: int) -> AdvExtract: + from ..unity import reader as unity + row = _master_row(adv_id, master_dir) a = row["_advEpisodeAsset"] base = f"Adv/Episode/{a}/{a}" @@ -102,19 +121,40 @@ def extract(cat: Catalog, master_dir: Path, adv_id: int) -> AdvExtract: if coll is None: raise RuntimeError(f"episode Collection not found for {a}") - # 2) shards - def shard(suf): - t = _text_of(cat, f"{base}{suf}") - return unity.parse_shard(t)["rows"] if t else [] + # 2) shards and the catalog-backed lookups of the resource closure + shards = {suf: _text_of(cat, f"{base}{suf}") for suf in SHARD_SUFFIXES} + + chats: dict = {} + + def chat(chat_id: int) -> dict: + if not chats: + chats.update({r["_id"]: r for r in master.table(master_dir, "MasterAdvChat")}) + if chat_id not in chats: + raise KeyError(f"MasterAdvChat has no row {chat_id}") + return chats[chat_id] + + title = _master_text(row["_titleTextId"], master_dir) if row["_titleTextId"] else None + return build(row, coll, shards, cat.has, chat, lambda: _default_transition(cat), title) + + +def build(row: dict, coll: list[dict], shards: dict, has, chat, transition, title: dict | None) -> AdvExtract: + """The episode of the MasterAdv `row` from its command list `coll` (the raw `Collection` rows) and its shard texts + `shards` ({suffix: JSON text or None} for SHARD_SUFFIXES; None or missing means no rows). `has`, `chat` and + `transition` are as in `closure`; `transition()` is called at most once. `title` is the MasterText lines of the + episode's title, or None.""" + + def shard(suf: str) -> list[dict]: + t = shards.get(suf) + return parse_shard(t)["rows"] if t else [] text_rows = shard("-Text") sound_rows = shard("-Sound") cue_rows = shard("-SoundCueSheet") video_rows = shard("-Video") - text_idx = unity.shard_index(text_rows) - sound_idx = unity.shard_index(sound_rows) + text_idx = shard_index(text_rows) + sound_idx = shard_index(sound_rows) - # 3) enrich commands + # enrich commands out_cmds = [] for c in coll: name = COMMAND.get(c["Command"], f"Cmd{c['Command']}") @@ -135,27 +175,19 @@ def shard(suf): item["lines"] = {lang[1:]: r.get(lang, "") for lang in LANGS} out_cmds.append(item) - # 4) resource closure - chats: dict = {} - default_transition: list[str] = [] - - def chat(chat_id: int) -> dict: - if not chats: - chats.update({r["_id"]: r for r in master.table(master_dir, "MasterAdvChat")}) - if chat_id not in chats: - raise KeyError(f"MasterAdvChat has no row {chat_id}") - return chats[chat_id] + # resource closure + default: list[str] = [] - def transition() -> str: - if not default_transition: - default_transition.append(_default_transition(cat)) - return default_transition[0] + def default_transition() -> str: + if not default: + default.append(transition()) + return default[0] - resources = closure(coll, cat.has, unity.shard_index(video_rows), chat, transition) + resources = closure(coll, has, shard_index(video_rows), chat, default_transition) return AdvExtract( - adv_id=adv_id, - asset=a, + adv_id=row["_id"], + asset=row["_advEpisodeAsset"], commands=out_cmds, text={r["_id"]: {lang[1:]: r.get(lang, "") for lang in LANGS} for r in text_rows if "_id" in r}, sounds={r["_id"]: r for r in sound_rows if "_id" in r}, @@ -163,7 +195,7 @@ def transition() -> str: videos={r["_id"]: r for r in video_rows if "_id" in r}, resources=resources, master=row, - title=_master_text(row["_titleTextId"], master_dir) if row["_titleTextId"] else None, + title=title, ) @@ -172,6 +204,21 @@ def _name(v) -> str | None: return v if isinstance(v, str) and v.strip() else None +def effect_instances(commands: list[dict]) -> dict[str, str]: + """TargetName -> TargetAssetName of the effect instances (LoadParticleEffect: the first asset loaded under a + TargetName is the instance every later Effect row with that TargetName drives).""" + out: dict[str, str] = {} + for c in commands: + if ( + c["cmd"] == "Effect" + and not c.get("IgnoreData") + and _name(c.get("TargetName")) + and _name(c.get("TargetAssetName")) + ): + out.setdefault(c["TargetName"], c["TargetAssetName"]) + return out + + def closure(rows: list[dict], has, videos: dict, chat, default_transition) -> list[dict]: """The assets AddEpisodeLoadingTask loads for the episode's rows (raw `Collection` rows, `Command` numbers), as [{kind, address, present}] sorted by (kind, address). @@ -224,6 +271,8 @@ def add(kind: str, address: str) -> None: def _default_transition(cat: Catalog) -> str: """AdvPlayerSettings._defaultTransitionAssetAddress (embedded content: the catalog needs the APK's).""" + from ..unity import reader as unity + if not cat.has(PLAYER_SETTINGS_KEY): raise apk_missing("the default ADV transition (AdvPlayerSettings)") for p in cat.fetch_key(PLAYER_SETTINGS_KEY): diff --git a/src/nnnotes/content/advmedia.py b/src/nnnotes/content/advmedia.py deleted file mode 100644 index c5b9608..0000000 --- a/src/nnnotes/content/advmedia.py +++ /dev/null @@ -1,206 +0,0 @@ -"""ADV episode assets beyond the scene: frames, particle effects, post-effect profiles, stills, chat and talk windows. - -One JSON file per kind at the story root, written only when the episode's resource closure (adv.closure) has that -kind; textures go to the story's `textures/` (PNG, named like the scene's) and every Shader object of the loaded -bundle closures is handed back to advscene, which dumps them with the scene's shaders into `shaders/`: - - frames.json Frame: "Adv/Frame/" + TargetAssetName (UIAdvWidget.SetFrame / AdvFrame), keyed by - TargetAssetName; each a prefab node list (AdvFrame, CanvasGroup, uGUI, Animator with its - controller and clips, UIParticle / ParticleSystem). Frame texts (TargetTextIDs) are episode - text ids (episode.json `text`). - effects.json Effect: "Adv/Effect/" + TargetAssetName, instantiated once per TargetName - (AdvEpisodeResourceLoader.LoadParticleEffect): `effects` by TargetAssetName (AdvParticleEffect, - ParticleSystem modules and renderers, Animator), `instances` TargetName -> TargetAssetName - posteffects.json PostEffect: "Adv/PostEffect/" + TargetAssetName, a URP VolumeProfile (not instantiated; - AdvGlobalVolume.ToggleVolume adds it as a child volume), keyed by TargetAssetName - stills.json Still: "Adv/Still/" + TargetAssetName (AdvStill, CanvasGroup, uGUI, DOTweenSequence - animations), keyed by TargetAssetName - chat.json ChatWindow / ChatTalk / ChatStamp: `chats` (the MasterAdvChat rows of the episode's chat ids), - `windows` ("Adv/Chat/Prefabs/" + _chatWindowAssetName, AdvChatWindow prefabs), `icons` - ("Adv/Chat/Icon/" + _chatIconAssetName) and `stamps` ("Adv/Chat/Stamp/" + TargetAssetName) as - sprites, `text` / `sounds` / `cueSheets`: the rows of the shared chat shards - (Adv/Chat/AdvChat-Text, -Sound, -SoundCueSheet); the cue sheet those rows name, AdvSe_Common, - is in no language's catalog and not in the APK (1.0.1 data), so nothing is decoded for them - talkwindows.json TalkWindow: "UI/Prefab/Parts/Adv/Talk/" + TargetAssetName (embedded content), keyed by window - name (the default window is also in ui/ui.json in its drawn form) - -Prefabs are exported with export.Exporter (references resolved, AnimatorControllers followed, clips in the -generic format). TextMeshPro text components always keep their layout and style (size, alignment, colours, -margins, text). The font data follows `fonts`: "open" (default) exports none of it -- font / sprite / style sheet -assets become {asset, name} and TextMeshPro materials {material, shader}, each with a note, so a viewer draws the -text with a font of its own; "game" exports the font assets and their materials with the atlas textures like any -other asset. A MonoBehaviour whose script is missing (Unity runs nothing for it) is recorded as such. -""" - -from __future__ import annotations - -from pathlib import Path - -from ..core.jsonio import write_json -from ..source import master as master_mod -from ..source.catalog import Catalog -from ..unity import fontsettings -from ..unity import reader as unity -from ..unity.export import HEADER, Exporter -from ..unity.player import PlayerData -from . import adv - -TMP_SHADER_PREFIX = "TextMeshPro/" -TMP_ASSETS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet") -FONT_CHOICES = ("open", "game") -FONT_NOTE = "font data not exported (fonts: open): draw the text with a font of your own" -CHAT_SHARDS = { - "text": "Adv/Chat/AdvChat-Text", - "sounds": "Adv/Chat/AdvChat-Sound", - "cueSheets": "Adv/Chat/AdvChat-SoundCueSheet", -} -# resource kind -> (story.json key = file key, file, address prefix removed from the entry keys; None: last segment) -FILES = { - "frame": ("frames", "frames.json", adv.RESOURCE_PREFIX["Frame"][1]), - "effect": ("effects", "effects.json", adv.RESOURCE_PREFIX["Effect"][1]), - "posteffect": ("postEffects", "posteffects.json", adv.RESOURCE_PREFIX["PostEffect"][1]), - "still": ("stills", "stills.json", adv.RESOURCE_PREFIX["Still"][1]), - "talkwindow": ("talkWindows", "talkwindows.json", None), -} -CHAT_KINDS = ("chatwindow", "chaticon", "chatstamp") -CHAT = ("chat", "chat.json") -KINDS = tuple(FILES) + CHAT_KINDS -INDEX_KEYS = tuple(k for k, _, _ in FILES.values()) + (CHAT[0],) - - -class MediaExporter(Exporter): - """export.Exporter for uGUI and particle prefabs: AnimatorControllers followed, missing-script MonoBehaviours - recorded; with fonts="open" the TextMeshPro font / sprite / style sheet assets and materials as name stubs.""" - - def __init__(self, cat: Catalog, out_dir: Path, player: PlayerData, fonts: str = "open"): - if fonts not in FONT_CHOICES: - raise ValueError(f"fonts={fonts!r}") - super().__init__( - cat, - out_dir, - player=player, - stub_assets=TMP_ASSETS if fonts == "open" else (), - follow=("AnimatorController",), - font_stubs=fontsettings.builtin() if fonts == "open" else None, - ) - self.fonts = fonts - - def scriptable(self, o) -> dict: - out = super().scriptable(o) - if self.fonts == "open" and out.get("asset") in TMP_ASSETS: - return {**out, "note": FONT_NOTE} - return out - - def material(self, o) -> dict: - if self.fonts == "game": - return super().material(o) - tt = o.read_typetree() - shader = self.deref(o, tt["m_Shader"]) - name = self.add_shader(shader) if shader is not None else None - if name is None or not name.startswith(TMP_SHADER_PREFIX): - return super().material(o) - return {"material": tt["m_Name"], "shader": {"shader": name}, "note": FONT_NOTE} - - def font_stub(self, o) -> dict: - """As material and scriptable: a TMP material or asset of a font bundle is only named, with FONT_NOTE.""" - out = super().font_stub(o) - if o.rec["type"] == "Material": - name = out["shader"]["shader"] - if not name.startswith(TMP_SHADER_PREFIX): - return out - return {"material": out["material"], "shader": {"shader": name}, "note": FONT_NOTE} - return {**out, "note": FONT_NOTE} if out["asset"] in TMP_ASSETS else out - - def component(self, o) -> dict: - if o.type.name == "MonoBehaviour": - tt = o.read_typetree() - if not tt["m_Script"]["m_PathID"]: - return { - "type": "MonoBehaviour", - "class": None, - "missingScript": True, - **{k: self.value(o, v) for k, v in tt.items() if k not in HEADER}, - } - return super().component(o) - - -def _suffix(address: str, prefix: str | None) -> str: - if prefix is None: - return address.rsplit("/", 1)[-1] - if not address.startswith(prefix): - raise ValueError(f"{address}: not under {prefix}") - return address[len(prefix) :] - - -def effect_instances(commands: list[dict]) -> dict[str, str]: - """TargetName -> TargetAssetName of the effect instances (LoadParticleEffect: the first asset loaded under a - TargetName is the instance every later Effect row with that TargetName drives).""" - out: dict[str, str] = {} - for c in commands: - if ( - c["cmd"] == "Effect" - and not c.get("IgnoreData") - and adv._name(c.get("TargetName")) - and adv._name(c.get("TargetAssetName")) - ): - out.setdefault(c["TargetName"], c["TargetAssetName"]) - return out - - -def _chat(cat: Catalog, master: Path, ex: Exporter, episode: dict, resources: list[dict]) -> dict: - rows = {r["_id"]: r for r in master_mod.table(master, "MasterAdvChat")} - ids = sorted({c["TargetChatID"] for c in episode["commands"] if c.get("TargetChatID")}) - doc: dict = {"chats": {str(i): rows[i] for i in ids if i in rows}, "windows": {}, "icons": {}, "stamps": {}} - for r in resources: - if r["kind"] == "chatwindow": - doc["windows"][_suffix(r["address"], adv.CHAT_WINDOW_PREFIX)] = ex.export_key(r["address"]) - elif r["kind"] == "chaticon": - doc["icons"][_suffix(r["address"], adv.CHAT_ICON_PREFIX)] = ex.key_sprite(r["address"]) - elif r["kind"] == "chatstamp": - doc["stamps"][_suffix(r["address"], adv.RESOURCE_PREFIX["ChatStamp"][1])] = ex.key_sprite(r["address"]) - for name, key in CHAT_SHARDS.items(): - t = adv._text_of(cat, key) - doc[name] = unity.parse_shard(t)["rows"] if t else [] - return doc - - -def extract(cat: Catalog, player: PlayerData, master: Path, episode: dict, out_dir: Path, fonts: str = "open") -> dict: - """Write the kind files the episode needs (`fonts`: see the module docstring). Returns {"files": {index key: - file or None} for every INDEX_KEYS entry, "shaders": {name: Shader object}, "counts": {index key: entries}, - "textures": textures written}.""" - out_dir = Path(out_dir) - by_kind: dict[str, list[dict]] = {} - for r in episode["resources"]: - if r["kind"] in KINDS: - by_kind.setdefault(r["kind"], []).append(r) - files: dict[str, str | None] = dict.fromkeys(INDEX_KEYS) - shaders: dict = {} - counts: dict[str, int] = {} - textures = 0 - - def done(ex: Exporter, name: str, doc: dict) -> None: - nonlocal textures - ex.resolve_pending() - write_json(out_dir / name, doc) - for k, o in ex.shaders.items(): - shaders.setdefault(k, o) - textures += len(ex.textures) - - for kind, (key, name, prefix) in FILES.items(): - if kind not in by_kind: - continue - ex = MediaExporter(cat, out_dir, player, fonts) - entries = {_suffix(r["address"], prefix): ex.export_key(r["address"]) for r in by_kind[kind]} - doc: dict = {key: entries} - if kind == "effect": - doc["instances"] = effect_instances(episode["commands"]) - done(ex, name, doc) - files[key] = name - counts[key] = len(entries) - chat = [r for k in CHAT_KINDS for r in by_kind.get(k, [])] - if chat: - ex = MediaExporter(cat, out_dir, player, fonts) - done(ex, CHAT[1], _chat(cat, master, ex, episode, chat)) - files[CHAT[0]] = CHAT[1] - counts[CHAT[0]] = len(chat) - return {"files": files, "shaders": shaders, "counts": counts, "textures": textures} diff --git a/src/nnnotes/content/advscene.py b/src/nnnotes/content/advscene.py deleted file mode 100644 index 26ac2a1..0000000 --- a/src/nnnotes/content/advscene.py +++ /dev/null @@ -1,108 +0,0 @@ -"""ADV presentation data -> scene.json + textures + shaders. - -Everything the game uses to present an ADV episode besides the episode script, -the Live2D models (with the materials of their mask pass: webmodel.py) and the -audio: - - player colour space, quality levels, URP pipelines, renderer data and - features, post-process data (APK boot data, see player.py) - cameraManager the app's Main/Sub camera prefab - advScene the ADV scene (AdvSceneRoot + scene camera) - characterField AdvCharacterField prefab (Field / Anchors / Stages) - backgroundField AdvBackgroundField prefab (backdrop mesh + sprite renderer) - globalVolume AdvGlobalVolume prefab (volumes + profiles) - settings AdvPlayerSettings (+ focus data), AdvMasterIdSettings - stages every stage prefab the episode uses (AdvStage, lights, - background sprite, volume profiles) - postTextures per camera renderer: post-process data textures used by the - URP chain (film grain textures, indexed by FilmGrain.type) - -Objects are exported with export.Exporter (references resolved, textures as -PNG). Shaders referenced anywhere, every Shader in the loaded bundle closures, -the ADV camera renderer's shaders and the shaders handed in by the caller (those -of advmedia's kind files) are dumped with shader.py into one `shaders/`. -""" - -from __future__ import annotations - -from pathlib import Path - -from ..core.jsonio import write_json -from ..formats import shader as shader_mod -from ..source.catalog import Catalog -from ..unity.export import Exporter -from ..unity.player import PlayerData - -ADV_KEYS = { - "cameraManager": "EmbCommon/Prefab/CameraManager", - "advScene": "EmbScene/Adv", - "characterField": "EmbCommon/Adv/Prefab/AdvCharacterField", - "backgroundField": "EmbCommon/Adv/Prefab/AdvBackgroundField", - "globalVolume": "EmbCommon/Adv/Prefab/AdvGlobalVolume", -} -SETTINGS_KEYS = { - "playerSettings": "EmbCommon/Adv/Settings/AdvPlayerSettings", - "masterIdSettings": "EmbCommon/Adv/Settings/AdvMasterIdSettings", -} -STAGE_PREFIX = "Adv/Stage/" - - -def camera_renderer(player_graphics: dict, pipeline: str, renderer_index: int) -> str: - rl = player_graphics["pipelines"][pipeline]["m_RendererDataList"] - i = player_graphics["pipelines"][pipeline]["m_DefaultRendererIndex"] if renderer_index < 0 else renderer_index - return rl[i] - - -def extract(cat: Catalog, player: PlayerData, episode: dict, out_dir: Path, shaders: dict | None = None) -> dict: - """Write scene.json, textures/ and shaders/. `shaders`: further {name: Shader object} to dump with the scene's - (the scene's object wins for a name both have).""" - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - ex = Exporter(cat, out_dir, player=player) - graphics = player.graphics() - doc: dict = {"player": graphics} - for name, key in ADV_KEYS.items(): - doc[name] = ex.prefab(key) - doc["settings"] = {name: ex.asset(key) for name, key in SETTINGS_KEYS.items()} - - stages = {} - for c in episode["commands"]: - if c["cmd"] == "Stage" and c.get("TargetAssetName"): - key = STAGE_PREFIX + c["TargetAssetName"] - stages.setdefault(c["TargetAssetName"], None) - if stages[c["TargetAssetName"]] is None: - stages[c["TargetAssetName"]] = ex.prefab(key) - doc["stages"] = stages - - # shaders: everything referenced above, the ADV camera renderer - renderers = set() - for node in doc["cameraManager"]["nodes"]: - for comp in node["components"]: - if comp.get("class") == "UniversalAdditionalCameraData": - renderers.add(camera_renderer(graphics, graphics["defaultPipeline"], comp["m_RendererIndex"])) - for rn in sorted(renderers): - for o in player.renderer_shaders(rn): - ex._shader(o) - for name, o in (shaders or {}).items(): - ex.shaders.setdefault(name, o) - # post-process textures the URP chain samples (film grain lookup by FilmGrain.type) - doc["postTextures"] = { - rn: {"filmGrainTex": [ex.texture(o) for o in player.post_textures(rn, "filmGrainTex")]} - for rn in sorted(renderers) - } - sdir = out_dir / "shaders" - index: list = [] - for name in sorted(ex.shaders): - shader_mod.dump_objects([ex.shaders[name]], sdir, ex.shaders[name].assets_file.name, index) - shader_summary = shader_mod.write_index(index, sdir) - doc["shaders"] = { - "index": "shaders/shaders.json", - "names": shader_summary["names"], - "cameraRenderers": sorted(renderers), - } - doc["convention"] = ( - "Unity space (left-handed, Y up); transforms are local TRS; " - "quaternions (x,y,z,w); colours in the project's Gamma space" - ) - write_json(out_dir / "scene.json", doc) - return {"stages": list(stages), "textures": len(ex.textures), **shader_summary} diff --git a/src/nnnotes/content/advui.py b/src/nnnotes/content/advui.py index cca94e5..00beb55 100644 --- a/src/nnnotes/content/advui.py +++ b/src/nnnotes/content/advui.py @@ -1,1087 +1,35 @@ -"""ADV story UI -> /ui/ (ui.json, textures/, shaders/). - -Everything a player needs to draw the ADV front canvas (talk window, speaker -plate, location caption, episode title, rule transition cover, menu entry -button, menu panel and video buttons, backlog, choices, curtains, flash, -subtitles, next indicator, the tap areas), the screen canvases of stills, -frames and videos, the letterbox bands and the dialogs of the ADV screen, -taken from the game data: - - nodes RectTransform hierarchy of the drawn parts of UIAdvWidget - (DRAWN) with the talk windows under UIContainer/TalkView - (AdvTalkView.SetWindow): the default UIDefaultTalkWindow, then - the other windows the episode's TalkWindow rows attach, and the - front canvas' CenterTalkBackdrop when one of them uses the - backdrop filter (UIAdvTalkWindow._useBackdropFilter); per - node the uGUI components the runtime needs (Canvas and scaler, - Image, RawImage, CanvasGroup, UIGradientImage, layout groups, - DOTweenSequence durations, the views' serialized references as - node paths, `behaviours`: every serialized field of the other - game components of BEHAVIOURS, references as node paths and - sprites as names) and, for a TMP text, its text record `textStyle` - (textstyle.py: layout, colours, style flags, face / outline / - underlay values in em, font role) - videoAndStillCamera the camera the still and video canvases render with - (UIAdvWidget._videoAndStillCamera) and the screen image showing - its output (_videoAndStillScreenImage) - uiCamera the UI camera of the UIManager prefab, which renders the frame - canvas (its canvas camera is assigned at run time), in the form - of videoAndStillCamera - masterIdTexts the AdvMasterIdSettings texts of the skip and video skip - dialogs and the interruption dialog in the language (with the - master data) - dialogs the skip confirm dialog (UIAdvSkipConfirmDialogWidget) and - CommonDialogManager's dialog (UICommonDialogWidget) as node - records with text records - chatWidget the phone of the chat rows (UIAdvChatWidget): its canvas and - scaler, AdvChatView with its durations and eases, the window - parent rect - chatTexts per chat window of the episode (AdvChatWindow prefab), the text - record of each TMP text in the language, and chatStatusTexts: - the incoming call / lock screen status texts the windows set - blur with a backdrop-filter talk window: the UI blur pass settings - (the UIRendererFeature of renderer 0: Dual Kawase iterations, - offset, downsample, blend rate maximum, shader) - sprites sprite geometry (rect, border, pivot, pixels per unit, texture - rect/offset) remapped into packed textures - materials UI-Transition, the built-in Default UI Material, the materials - of the drawn images - clips Animator clips (NextIndicator, AutoNext, AdvLocation/AdvTitle Play) - transitions RuleTransitionSettings of every transition the episode or the - player settings reference - shaders UI/Default, UI/Transition, the blur shader with `blur` - -Language (`language`, a catalog language code; default `[catalog] language`): -the LanguageMode of the localized fonts, materials and line spacing -(LocalizeText) and the text field of the lines the episode shows. - -Fonts (`fonts`): "open" (default) writes no font data; a renderer draws the -text records with fonts of its own. "game" also writes the game's TMP font -data (tmpfont.py, optional `fonts` extra): per node the serialized TMP text -settings with the localized font / material (`text`), the TMP font assets -reached from the localized fonts (face info, style values, fallback chain) -with the character and glyph tables reduced to the characters the episode -shows (characters a dynamic font asset would add at runtime are generated the -way FontEngine renders them, see tmpfont.RuntimeGlyphs), their materials, the -glyph coverage, the TMP settings and the TextMeshPro/Mobile/Distance Field -shader. - -Textures: sprite atlases and SDF font atlases are large; only the texel blocks -a draw can sample are kept (export.Exporter deferred-texture mode with -export.TexelPacker). Each block is the sprite's texture rect or a glyph rect -grown by the widest footprint the shader samples, plus one texel for bilinear -filtering, read with the source wrap mode, and copied into a packed texture. -UVs are remapped by an integer translation, so sampling returns the same texel -values as the full atlas. -""" +"""TextMesh Pro sprite metadata, emoji sequence lookup and chat runtime text.""" from __future__ import annotations -import re -from collections.abc import Sequence -from pathlib import Path +from ..unity import textstyle -from ..core.jsonio import write_json -from ..source import languages -from ..source.catalog import Catalog -from ..unity import fontsettings, textstyle, tmpfont -from ..unity.export import Exporter, TexelPacker, _safe -from ..unity.player import PlayerData -from ..unity.textstyle import LANGUAGE_LINE_SPACING -from . import adv, advscene - -WIDGET_KEY = "EmbUI/Prefab/UIAdvWidget" # UIManager: UIManager._cameraController -> UICameraController._uiCamera / _uiCameraData -UI_MANAGER_KEY = "EmbCommon/Prefab/UIManager" -WINDOW_KEY = "EmbUI/Prefab/Parts/Adv/Talk/UIDefaultTalkWindow" -WINDOW_PARENT = "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TalkView" # UIAdvWidget._centerTalkBackdropCanvasGroup / _centerTalkBackdropImage: the backdrop of a backdrop-filter talk window -BACKDROP_REFS = ("_centerTalkBackdropCanvasGroup", "_centerTalkBackdropImage") -LETTERBOX_KEY = "Textures/letterbox-image" -TRANSITION_PREFIX = "Adv/Transition/" -PLAYER_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvPlayerSettings" -MASTER_ID_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvMasterIdSettings" -CLIP_KEYS = { - "NextIndicator/Loop": "EmbUI/Animations/NextIndicator/Loop", - "AutoNext/Loop": "EmbUI/Animations/Auto/Loop", - "AdvLocation/Play": "EmbUI/Animations/AdvLocation/Play", - "AdvTitle/Play": "EmbUI/Animations/AdvTitle/Play", -} -CONTROLLER_KEYS = { - "NextIndicator": "EmbUI/Animations/NextIndicator/NextIndicator", - "AutoNext": "EmbUI/Animations/Auto/AutoNext", - "AdvLocation": "EmbUI/Animations/AdvLocation/AdvLocation", - "AdvTitle": "EmbUI/Animations/AdvTitle/AdvTitle", -} # Parts of the widget the runtime draws (and their ancestors). The block canvas # is not exported. -DRAWN = ( - "UIAdvWidget/VideoCanvas", - "UIAdvWidget/StillCanvas", - "UIAdvWidget/VideoAndStillRenderScreenCanvas", - "UIAdvWidget/FrameCanvas", - "UIAdvWidget/FrontCanvas/FlashView", - "UIAdvWidget/FrontCanvas/RuleTransition", - "UIAdvWidget/FrontCanvas/LocationVIew", - "UIAdvWidget/FrontCanvas/RightCurtain", - "UIAdvWidget/FrontCanvas/LeftCurtain", - "UIAdvWidget/FrontCanvas/CancelFullScreenButton", - "UIAdvWidget/FrontCanvas/TalkLogView", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/NextButton", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/ChoiceView", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/SubtitlesView", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TalkView", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/NextIndicator", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/MenuButtonsParent", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/MenuEntryButton", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/MenuView/VideoButtons", - "UIAdvWidget/FrontCanvas/UISafeArea/UIContainer/TitleView", - "UIAdvWidget/AdvLetterBoxCanvas", -) # the view components of the drawn parts: record key and the serialized references kept (as node paths) -VIEWS = { - "AdvVideoView": ("videoView", ("_video", "_maskArea", "_curtainCanvasGroup", "_videoCanvasGroup")), - "AdvStillView": ("stillView", ("_overlay", "_background", "_target")), - "AdvFrameView": ("frameView", ()), - "AdvFlashView": ("flashView", ("_flash",)), - "AdvSubtitlesView": ("subtitlesView", ("_subtitles", "_subtitlesText")), - "AdvFrontScreenView": ( - "frontScreenView", - ("_nextButton", "_nextIndicator", "_cancelFullScreenButton", "_uiContainer"), - ), -} # views recorded with every serialized field (references as node paths) -FULL_VIEWS = {"AdvTalkLogView": "talkLogView", "AdvChoiceView": "choiceView"} # the other game and uGUI components recorded in `behaviours` (per class, every component of the node in order): # buttons and their state and animation parts, raycast targets, colour synchronizers, texts' UIText, safe areas, # scrolling, DOTween animations, localized sprites, the backlog entry and choice item views, the dialog parts -BEHAVIOURS = ( - "UIButton", - "ButtonActiveState", - "ButtonAnimationScale", - "ButtonSound", - "SimpleRaycastTarget", - "GraphicColorSynchronizer", - "UIText", - "AspectRatioFitter", - "UIToggle", - "UIToggleGroup", - "UIToggleButtonFrame", - "UIDecoratedNormalButton", - "UIDecoratedNormalButtonFrame", - "UIAddressableImage", - "UISafeArea", - "AdvViewportSafeArea", - "EnhancedScrollRect", - "EnhancedScroller", - "ScrollRect", - "RectMask2D", - "Scrollbar", - "DOTweenAnimation", - "LocalizeSpriteEvent", - "AdvTalkLogEntryView", - "AdvChoiceItem", - "DialogButtons", - "DialogAnimation", - "DialogSizeFitter", - "UIAdvSkipConfirmDialogWidget", - "UICommonDialogWidget", -) -SERIALIZED_SKIP = ( - "type", - "class", - "m_GameObject", - "m_Script", - "m_Name", - "m_EditorHideFlags", - "m_EditorClassIdentifier", -) # the dialogs of the ADV screen: the skip confirm dialog and CommonDialogManager's dialog widget -DIALOG_KEYS = { - "UIAdvSkipConfirmDialogWidget": "EmbUI/Prefab/UIAdvSkipConfirmDialogWidget", - "UICommonDialogWidget": "EmbUI/Prefab/UICommonDialogWidget", -} -CAMERA_FIELDS = ( - "m_Enabled", - "m_ClearFlags", - "m_BackGroundColor", - "m_NormalizedViewPortRect", - "near clip plane", - "far clip plane", - "field of view", - "orthographic", - "orthographic size", - "m_Depth", - "m_CullingMask", - "m_HDR", - "m_AllowMSAA", -) -CAMERA_DATA_FIELDS = ( - "m_RenderPostProcessing", - "m_VolumeLayerMask", - "m_RendererIndex", - "m_Antialiasing", - "m_AntialiasingQuality", - "m_RenderShadows", - "m_RequiresDepthTextureOption", - "m_RequiresOpaqueTextureOption", - "m_CameraType", - "m_VolumeFrameworkUpdateModeOption", - "m_Dithering", - "m_StopNaN", -) # AdvMasterIdSettings text ids the drawn parts show: the video skip dialog (AdvMenuView's skip video button), the # skip confirm dialog and the interruption dialog -MASTER_ID_TEXTS = ( - "_skipVideoMessageTextId", - "_skipButtonTextId", - "_cancelButtonTextId", - "_skipMessageTextId", - "_continuousSkipMessageTextId", - "_backEpisodeListButtonTextId", - "_continueEpisodeButtonTextId", - "_interruptionTitleTextId", - "_interruptionMessageTextId", - "_interruptionButtonTextId", -) -RUBY = re.compile(r"]*)>") # ruby reading markup of the text tables: base # the phone of the chat rows: UIAdvChatWidget's canvas, AdvChatView and the parent of the attached window -CHAT_WIDGET_KEY = "EmbUI/Prefab/UIAdvChatWidget" -CHAT_WIDGET_NODES = ( - "UIAdvChatWidget/ChatCanvas", - "UIAdvChatWidget/ChatCanvas/AdvChatView", - "UIAdvChatWidget/ChatCanvas/AdvChatView/Target", -) -CHAT_VIEW_FIELDS = ( - "_showEaseDuration", - "_hideEaseDuration", - "_showEase", - "_hideEase", - "_scrollDuration", - "_typingDelay", - "_typingTextBoxMinHeight", - "_screenModeTransitionDuration", - "_screenModeTransitionEase", - "_incomingCallPositionOffset", -) -CHAT_STATUS_KEYS = ("_incomingCallStatusTextKey", "_lockScreenStatusTextKey") # AdvChatWindow.RefreshStatusTexts CHAT_COMMANDS = ("ChatWindow", "ChatTalk", "ChatStamp", "ChatRead", "ChatTyping") # AdvMenuView: its serialized references (node paths), the fast-forward button sprites (AdvMenuView.SetFastForwardSpeed) -MENU_VIEW_REFS = ( - "_menuButtonsParent", - "_menuEntryButton", - "_skipButton", - "_autoButton", - "_fastForwardButton", - "_logButton", - "_continuousButton", - "_fullScreenButton", - "_interruptionButton", - "_menuButtonsBackground", - "_canvasGroup", - "_skipVideoButton", - "_pauseVideoButton", - "_subtitlesButton", - "_videoButtonParent", - "_videoFilterButton", - "_fastForwardButtonImage", -) -MENU_VIEW_SPRITES = ( - "_fastForwardNormalSprite", - "_fastForwardOnePointFiveSprite", - "_fastForwardOnePointSevenSprite", - "_fastForwardDoubleSprite", -) EMOJI_SPRITE_ASSET_KEY = "Font/SiriusEmoji/SiriusEmojiData" # LocalizeManager.EmojiSpriteAssetAddress -EMOJI_CLASSES = ("RubyEmojiTextMeshProUGUI",) # TMP_Text.GetUnderlineSpecialCharacter: the underline and the text highlight (DrawTextHighlight) draw with the '_' # of the text's font asset (no fallback), sampled on the asset's first atlas page UNDERLINE_CHARACTER = 0x5F TMP_CLASSES = ("TextMeshProUGUI", "RubyTextMeshProUGUI", "RubyEmojiTextMeshProUGUI") -TMP_FIELDS = ( - "m_fontSize", - "m_fontSizeBase", - "m_fontStyle", - "m_fontWeight", - "m_HorizontalAlignment", - "m_VerticalAlignment", - "m_characterSpacing", - "m_wordSpacing", - "m_lineSpacing", - "m_paragraphSpacing", - "m_characterHorizontalScale", - "m_TextWrappingMode", - "m_overflowMode", - "m_enableAutoSizing", - "m_fontSizeMin", - "m_fontSizeMax", - "m_enableKerning", - "m_ActiveFontFeatures", - "m_enableExtraPadding", - "m_isRichText", - "m_parseCtrlCharacters", - "m_isOrthographic", - "m_overrideHtmlColors", - "m_margin", - "m_fontColor", - "m_Color", - "m_colorMode", - "m_enableVertexGradient", - "m_TextStyleHashCode", - "m_useMaxVisibleDescender", - "m_horizontalMapping", - "m_verticalMapping", - "m_charWidthMaxAdj", - "m_lineSpacingMax", - "m_isRightToLeft", - "m_text", - "m_monospaceDistEm", # TMP_EmojiTextUGUI (the emoji text classes) -) -BLUR_FEATURE = "Fwk.UI.Rendering.UIRendererFeature" -FONT_MODES = ("open", "game") -IMAGE_FIELDS = ( - "m_Enabled", - "m_Color", - "m_Type", - "m_PreserveAspect", - "m_FillCenter", - "m_FillMethod", - "m_FillAmount", - "m_UseSpriteMesh", - "m_PixelsPerUnitMultiplier", - "m_Maskable", -) - - -def _strip_tags(s: str) -> str: - return re.sub(r"<[^<>]*>", "", s) - - -def _ref_path(v) -> str | None: - """The node path of a serialized reference as the exporter writes it ({component | transform | gameObject}).""" - if not v: - return None - return v.get("gameObject") or v.get("transform") - - -def _need_path(v) -> str: - """The node path of a serialized reference that must not be null (RuntimeError for a null one).""" - p = _ref_path(v) - if p is None: - raise RuntimeError(f"a required node reference is null ({v!r})") - return p - - -def _plain(v, sprites: list): - """A serialized value as plain data: a reference as its node path, a sprite as its name (its reference added to - `sprites`), a ScriptableObject or material as its name, a texture as its name; containers element-wise.""" - if isinstance(v, list): - return [_plain(x, sprites) for x in v] - if not isinstance(v, dict): - return v - if "spriteRef" in v: - sprites.append(v["spriteRef"]) - return v["name"] - if "gameObject" in v or "transform" in v: - return _ref_path(v) - if "asset" in v or ("material" in v and "shader" in v): - return v.get("name") or v.get("material") - if "textureRef" in v: - return v.get("name") - return {k: _plain(x, sprites) for k, x in v.items()} - - -def _serialized(c: dict, sprites: list) -> dict: - """Every serialized field of a component (SERIALIZED_SKIP left out), as _plain values.""" - return {k: _plain(v, sprites) for k, v in c.items() if k not in SERIALIZED_SKIP} - - -def _pictogram(c: dict) -> dict: - """App.UI.UIPictogram: its key, image and native-size flag and the catalog it draws from (the sprite is - resolved against the catalog in extract).""" - return { - "m_Enabled": c["m_Enabled"], - "_key": c["_key"], - "_image": _ref_path(c["_image"]), - "_setNativeSize": c["_setNativeSize"], - "catalog": (c["_spriteCatalog"] or {}).get("name"), - } - - -def _layout_components(node: dict) -> dict: - out: dict = {} - sprites: list = [] - behaviours: dict = {} - image_states = [c for c in node["components"] if c.get("class") == "ButtonImageState"] - for c in node["components"]: - cls = c.get("class") - t = c["type"] - if t == "CanvasGroup": - out["canvasGroup"] = {k: c[k] for k in ("m_Enabled", "m_Alpha", "m_IgnoreParentGroups")} - elif t == "Canvas": - out["canvas"] = { - k: c[k] - for k in ( - "m_Enabled", - "m_RenderMode", - "m_SortingOrder", - "m_VertexColorAlwaysGammaSpace", - "m_AdditionalShaderChannelsFlag", - "m_OverrideSorting", - "m_PixelPerfect", - ) - } - if c["m_RenderMode"] == 1: # screen space - camera: the plane it is drawn at, its camera if set - out["canvas"]["m_PlaneDistance"] = c["m_PlaneDistance"] - if c.get("m_Camera"): - out["canvas"]["camera"] = _ref_path(c["m_Camera"]) - elif t == "Animator": - out["animator"] = { - "controller": (c["m_Controller"] or {}).get("name"), - "enabled": c["m_Enabled"], - "updateMode": c["m_UpdateMode"], - "keepStateOnDisable": c["m_KeepAnimatorStateOnDisable"], - } - elif cls in ("CanvasScaler", "ClampedCanvasScaler"): - out["canvasScaler"] = { - k: c[k] - for k in ( - "m_Enabled", - "m_UiScaleMode", - "m_ReferencePixelsPerUnit", - "m_ScaleFactor", - "m_ReferenceResolution", - "m_ScreenMatchMode", - "m_MatchWidthOrHeight", - ) - } - if cls == "ClampedCanvasScaler": # Fwk.UI.ClampedCanvasScaler: the scale clamped past an aspect ratio - out["canvasScaler"].update({"class": cls, "_maxAspectThreshold": c["_maxAspectThreshold"]}) - elif cls == "Image": - img = {k: c[k] for k in IMAGE_FIELDS} - img["sprite"] = c["m_Sprite"]["name"] if c["m_Sprite"] else None - img["spriteRef"] = c["m_Sprite"]["spriteRef"] if c["m_Sprite"] else None - img["material"] = c["m_Material"]["material"] if c["m_Material"] else None - out["image"] = img - elif cls == "RawImage": - out["rawImage"] = {k: c[k] for k in ("m_Enabled", "m_Color", "m_UVRect", "m_RaycastTarget", "m_Maskable")} - out["rawImage"]["texture"] = (c["m_Texture"] or {}).get("name") - out["rawImage"]["material"] = c["m_Material"]["material"] if c["m_Material"] else None - elif cls == "UIGradientImage": - out["gradient"] = { - "enabled": c["m_Enabled"], - "gradient": c["_gradient"], - "angleDeg": c["_angleDeg"], - "splitAtKeysWhenAxisAligned": c["_splitAtKeysWhenAxisAligned"], - "blendMode": c["_blendMode"], - } - elif cls in TMP_CLASSES: - txt = {k: c[k] for k in TMP_FIELDS if k in c} - txt["class"] = cls - txt["enabled"] = c["m_Enabled"] - txt["fontAsset"] = c["m_fontAsset"]["name"] - txt["material"] = c["m_sharedMaterial"]["material"] - out["text"] = txt - elif cls == "LocalizeText": - out["localizeText"] = {"enabled": c["m_Enabled"], "localizeEnabled": c["_localizeEnabled"]} - elif cls in ("HorizontalLayoutGroup", "VerticalLayoutGroup"): - out["layoutGroup"] = {"class": cls, **{k: c[k] for k in c if k.startswith("m_")}} - elif cls == "ContentSizeFitter": - out["contentSizeFitter"] = {k: c[k] for k in ("m_Enabled", "m_HorizontalFit", "m_VerticalFit")} - elif cls == "LayoutElement": - out["layoutElement"] = {k: c[k] for k in c if k.startswith("m_")} - elif cls == "DOTweenSequence": - out["tweenSequence"] = { - "list": [{k: e[k] for k in ("_commandType", "_duration") if k in e} for e in c["_list"]], - "raw": {k: c[k] for k in c if k in ("updateType", "isSpeedBased")}, - } - elif cls == "SimpleAnimationTrigger": - out["animationTrigger"] = {"blendTime": c["_blendTime"]} - elif cls == "UIAdvTalkWindow": - out["talkWindow"] = { - k: c[k] - for k in ( - "_typingDelay", - "_safeAreaTalkBackgroundExpansionFactor", - "_useBackdropFilter", - "_backdropFilterColor", - "_talkTextColor", - "_talkTextOutlineColor", - ) - } - elif cls == "ButtonImageState" and len(image_states) > 1: - behaviours.setdefault(cls, []).append(_serialized(c, sprites)) - elif cls == "ButtonImageState": - out["buttonImageState"] = {"normal": c["_normaledSprite"]["name"] if c["_normaledSprite"] else None} - if c["_selectedSprite"]: # ButtonImageState.SetSelected: the sprite of the selected state - out["buttonImageState"].update( - { - "selected": c["_selectedSprite"]["name"], - "selectedRef": c["_selectedSprite"]["spriteRef"], - "target": _ref_path(c["_target"]), - } - ) - elif cls == "UISafeAreaEdgeAnchor": - out["safeAreaEdgeAnchor"] = { - "enabled": c["m_Enabled"], - **{k: c[k] for k in ("_left", "_right", "_top", "_bottom")}, - } - elif cls in VIEWS: - key, refs = VIEWS[cls] - out[key] = {"enabled": c["m_Enabled"], **{k: _ref_path(c[k]) for k in refs}} - elif cls == "AdvMenuView": - out["menuView"] = { - "enabled": c["m_Enabled"], - **{k: _ref_path(c[k]) for k in MENU_VIEW_REFS}, - **{k: c[k]["name"] if c[k] else None for k in MENU_VIEW_SPRITES}, - "_fadeDuration": c["_fadeDuration"], - } - sprites += [c[k]["spriteRef"] for k in MENU_VIEW_SPRITES if c[k]] - elif cls in FULL_VIEWS: - out[FULL_VIEWS[cls]] = {"enabled": c["m_Enabled"], **_serialized(c, sprites)} - elif cls == "UIPictogram": - behaviours.setdefault(cls, []).append(_pictogram(c)) - elif cls in BEHAVIOURS: - behaviours.setdefault(cls, []).append(_serialized(c, sprites)) - elif cls == "AdvChatView": - out["chatView"] = { - "enabled": c["m_Enabled"], - **{k: c[k] for k in CHAT_VIEW_FIELDS}, - "_windowParentRect": _ref_path(c["_windowParentRect"]), - } - elif cls == "Outline": - out["outline"] = {"enabled": c["m_Enabled"]} - if behaviours: - out["behaviours"] = behaviours - if sprites: - out["_spriteRefs"] = sprites # packed into the UI textures, removed in extract - return out # -------------------------------------------------------------------------- # main entry # -------------------------------------------------------------------------- -def _node_record(n: dict) -> dict: - """A node of ui.json: transform, rect and the uGUI component records (_layout_components).""" - rec = { - "path": n["path"], - "name": n["name"], - "active": n["active"], - "localPosition": n["localPosition"], - "localRotation": n["localRotation"], - "localScale": n["localScale"], - "rect": n.get("rect"), - } - rec.update(_layout_components(n)) - return rec - - -def _component(node: dict, classes: tuple) -> dict | None: - hit = [c for c in node["components"] if c.get("class") in classes] - if len(hit) > 1: - raise RuntimeError(f"{node['path']}: {len(hit)} components of {classes}") - return hit[0] if hit else None - - -def _need_component(node: dict, classes: tuple) -> dict: - """The one component of `classes` on `node` (RuntimeError when it has none).""" - c = _component(node, classes) - if c is None: - raise RuntimeError(f"{node['path']}: no component of {classes}") - return c - - -def extract( - cat: Catalog, - player: PlayerData, - episode: dict, - out_dir: Path, - fonts: str = "open", - *, - language: str, - master: Path | None = None, -) -> dict: - """Write /ui/. `fonts`: "open" (text records only) or "game" (also the game's TMP font data); - `language`: the client language (a languages.LANGUAGES code); - `master`: the decoded master data (MasterText of `masterIdTexts`; None: no `masterIdTexts`).""" - if fonts not in FONT_MODES: - raise ValueError(f"fonts must be one of {FONT_MODES}, not {fonts!r}") - language = languages.check(language) - mode, field = languages.mode(language), languages.column(language)[1:] - game = fonts == "game" - if game: - tmpfont.require_extra() - ui_dir = Path(out_dir) / "ui" - ui_dir.mkdir(parents=True, exist_ok=True) - ex = Exporter( - cat, - ui_dir, - player=player, - textures="deferred", - stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"), - font_stubs=None if game else fontsettings.builtin(), - ) - packer = TexelPacker(ui_dir) - - # -- prefabs ------------------------------------------------------------- - widget = ex.prefab(WIDGET_KEY)["nodes"] - windows = talk_windows(episode) - window = window_nodes(ex, windows) - paths = {n["path"] for n in widget} - if WINDOW_PARENT not in paths: - raise RuntimeError(f"{WINDOW_PARENT} not in {WIDGET_KEY}") - widget_comp = next(c for c in widget[0]["components"] if c.get("class") == "UIAdvWidget") - drawn = DRAWN + ((backdrop_path(widget, widget_comp),) if uses_backdrop_filter(window) else ()) - - def kept(p: str) -> bool: - return any(p == d or p.startswith(d + "/") or d.startswith(p + "/") for d in drawn) - - nodes = [] - for n in widget: - if n["path"] == "UIAdvWidget" or not kept(n["path"]): - continue - nodes.append(n) - if n["path"] == WINDOW_PARENT: - nodes += window - for d in drawn: - if d not in {n["path"] for n in nodes}: - raise RuntimeError(f"{d} missing from the prefabs") - fonts_of = tmpfont.AssetFontSettings(ex) if game else fontsettings.builtin() - styles = textstyle.TextStyles(ex, player, mode, fonts_of) - dialog_nodes = {name: ex.prefab(key)["nodes"] for name, key in DIALOG_KEYS.items()} - catalogs = pictogram_catalogs(widget + window + [n for dn in dialog_nodes.values() for n in dn]) - - def records(prefab_nodes: list, keep_text: bool) -> list: - out = [] - for n in prefab_nodes: - rec = _node_record(n) - if "text" in rec: - if not (rec.get("localizeText") or {}).get("localizeEnabled"): - raise NotImplementedError(f"{n['path']}: text without LocalizeText") - rec["textStyle"] = styles.record( - n["path"], _need_component(n, TMP_CLASSES), _component(n, ("LocalizeText",)) - ) - if not keep_text: - del rec["text"] - resolve_pictograms(rec, catalogs) - out.append(rec) - return out - - out_nodes = records(nodes, game) - dialogs = {name: {"key": DIALOG_KEYS[name], "nodes": records(dn, False)} for name, dn in dialog_nodes.items()} - front = [n for n in widget if n["path"].startswith("UIAdvWidget/FrontCanvas/") and n["path"].count("/") == 2] - front_order = [n["name"] for n in front] - widget_rec = widget_canvases(widget, widget_comp) - camera = _camera(widget, _ref_path(widget_comp["_videoAndStillCamera"])) - screen_image = _ref_path(widget_comp["_videoAndStillScreenImage"]) - if screen_image not in {n["path"] for n in out_nodes}: - raise RuntimeError(f"{screen_image} (_videoAndStillScreenImage) is not a drawn node") - master_ids = ex.asset(MASTER_ID_SETTINGS_KEY) # exported once: a second export is a reference - id_texts = master_id_texts(master_ids, master, field) if master is not None else None - chat_nodes = {n["path"]: n for n in ex.prefab(CHAT_WIDGET_KEY)["nodes"]} - missing = [p for p in CHAT_WIDGET_NODES if p not in chat_nodes] - if missing: - raise RuntimeError(f"{missing} missing from {CHAT_WIDGET_KEY}") - chat_widget = {"nodes": [_node_record(chat_nodes[p]) for p in CHAT_WIDGET_NODES]} - chat, status_ids = chat_texts(ex, styles.fonts, styles.lang, mode, episode) - status_texts = master_texts(master, status_ids, field) if master is not None and status_ids else None - - materials: dict = {} - if game: - materials, font_doc = _game_fonts( - ex, - styles, - episode, - out_nodes, - field, - master_ids, - [t["text"] for t in (id_texts or {}).values()] + list((status_texts or {}).values()), - ) - rule = next(n for n in out_nodes if n["path"].endswith("FrontCanvas/RuleTransition")) - rt_comp = [ - c - for c in next(w for w in widget if w["path"] == rule["path"])["components"] - if c.get("class") == "UIAdvRuleTransitionView" - ][0] - materials[rt_comp["_material"]["material"]] = rt_comp["_material"] - rule["ruleTransition"] = {"material": rt_comp["_material"]["material"]} - # Graphic.defaultGraphicMaterial (Canvas.GetDefaultCanvasMaterial): the player - # ships the built-in UI/Default shader (unity_builtin_extra) but no serialized - # "Default UI Material", so its values are the shader's property defaults. - ui_default = player.shader("UI/Default", file="unity_builtin_extra") - materials["Default UI Material"] = { - "material": "Default UI Material", - "shader": {"shader": "UI/Default"}, - "keywords": [], - "textures": {}, - "ints": {}, - "floats": {}, - "colors": {}, - "shaderDefaults": True, - } - # the materials of the drawn images (the video mask and the curtain it masks) - for n in nodes + [n for dn in dialog_nodes.values() for n in dn]: - for c in n["components"]: - if c.get("class") in ("Image", "RawImage") and c["m_Material"]: - materials.setdefault(c["m_Material"]["material"], c["m_Material"]) - - # -- glyph blocks ------------------------------------------------------------- - if game: - font_out = tmpfont.export_fonts( - ex, - font_doc["fonts"], - font_doc["primaries"], - font_doc["needed"], - font_doc["runtime"], - font_doc["textMaterials"], - packer, - "characters shown by this episode (plus baked control characters); runtime " - "characters generated from the source font (runtimeGlyphs)", - ) - for name, mg in font_doc["missingGlyph"].items(): - font_out[name]["missingGlyph"] = mg - - # -- sprites ------------------------------------------------------------------- - sprite_refs = set() - all_records = out_nodes + [n for d in dialogs.values() for n in d["nodes"]] - for n in all_records: - if n.get("image") and n["image"]["spriteRef"]: - sprite_refs.add(n["image"]["spriteRef"]) - if (n.get("buttonImageState") or {}).get("selectedRef"): - sprite_refs.add(n["buttonImageState"]["selectedRef"]) - sprite_refs.update(n.get("_spriteRefs", ())) - for p in (n.get("behaviours") or {}).get("UIPictogram", ()): - sprite_refs.update(r for r in (p.pop("_spriteRef", None), p.pop("_defaultRef", None)) if r) - lb_sprite = ex.key_sprite(LETTERBOX_KEY) - sprite_refs.add(lb_sprite["spriteRef"]) - sprites_out = {} - for ref in sorted(sprite_refs): - s = ex.sprite_recs[ref] - tex_name = s["tex"].read_typetree()["m_Name"] - ex.pack_sprite(ref, packer, "sprites" if "FixUiSpriteAtlas" in tex_name else f"sprite_{s['name']}") - # transitions - ps = ex.asset(PLAYER_SETTINGS_KEY) - addresses = {ps["_defaultTransitionAssetAddress"]} - for c in list(ps["_initializeEpisodes"]) + list(ps["_finalizeEpisodes"]) + episode["commands"]: - cmd = c.get("Command") if "Command" in c else c.get("raw") - if cmd in (5, 6) and c.get("TargetAssetName"): - addresses.add(c["TargetAssetName"]) - transitions = {} - for addr in sorted(addresses): - a = ex.asset(TRANSITION_PREFIX + addr) - tex = a["_texture"] - if tex is None: - raise RuntimeError(f"transition {addr}: no rule texture") - if tex["mipCount"] > 1: - raise NotImplementedError(f"transition {addr}: mipmapped rule texture") - o = ex.tex_objs[tex["textureRef"]] - a["_block"] = packer.add(f"rule_{_safe(addr)}", o, 0, 0, tex["width"], tex["height"]) - transitions[addr] = a - - textures = packer.build() - - # resolve blocks - if game: - tmpfont.resolve_font_blocks(font_out) - for ref in sorted(sprite_refs): - sprites_out[ex.sprite_recs[ref]["name"]] = ex.packed_sprite(ref) - for n in all_records: - if n.get("image"): - n["image"].pop("spriteRef", None) - if n.get("buttonImageState"): - n["buttonImageState"].pop("selectedRef", None) - n.pop("_spriteRefs", None) - for addr, a in transitions.items(): - b = a.pop("_block") - t = textures[b["texture"]] - # rule textures are sampled with screen-space UVs over [0, 1]: they stay whole - if b["offset"] != (0, 0) or (t["width"], t["height"]) != (a["_texture"]["width"], a["_texture"]["height"]): - raise RuntimeError(f"transition {addr}: rule texture not copied whole") - a["_texture"] = {"texture": b["texture"], "name": a["_texture"]["name"]} - for m in materials.values(): - for _k, v in m["textures"].items(): - if v["texture"] is not None: - v["texture"] = { - "name": v["texture"]["name"], - "width": v["texture"]["width"], - "height": v["texture"]["height"], - "format": v["texture"]["format"], - } - - # -- clips ---------------------------------------------------------------------- - clips = {} - for name, key in CLIP_KEYS.items(): - o = ex.key_object(key) - if o.type.name != "AnimationClip": - raise RuntimeError(f"{key}: container asset is {o.type.name}") - clips[name] = ex.clip_curves(o) - controllers = {name: ex.controller_states(ex.key_object(key)) for name, key in CONTROLLER_KEYS.items()} - - # -- DOTween defaults (Resources/DOTweenSettings) ------------------------------------ - dts = player.mono(player.resource("DOTweenSettings")) - - # -- shaders ---------------------------------------------------------------------- - needed = {} - for name in sorted({m["shader"]["shader"] for m in materials.values()}): - if name == "UI/Default": - needed[name] = ui_default - elif name in ex.shaders: - needed[name] = ex.shaders[name] - else: - raise RuntimeError(f"shader {name} not in the loaded bundles") - blur = None - if uses_backdrop_filter(window): - blur, blur_shader = blur_settings(player) - needed[blur["shader"]] = blur_shader - index, shader_summary = ex.dump_shaders(ui_dir / "shaders", needed) - if blur is not None: - gles = [ - v - for r in index - if r["name"] == blur["shader"] - for v in r["variants"] - if v["platform"] == "gles3" and v["type"] == "GLES3" - ] - if not any(v["keywords"] == [] and v["stage"] == "vertex" for v in gles): - raise RuntimeError(f"{blur['shader']}: no GLES3 variant without keywords (the blur pass)") - variants_needed = {} - for m in materials.values(): - sh = m["shader"]["shader"] - rec = next(r for r in index if r["name"] == sh) - known = {k for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3" for k in v["keywords"]} - want = sorted(k for k in m["keywords"] if k in known) - ok = any( - v["platform"] == "gles3" - and v["type"] == "GLES3" - and sorted(v["keywords"]) == want - and v["stage"] == "vertex" - for v in rec["variants"] - ) - if not ok: - raise RuntimeError(f"{sh}: no GLES3 variant for material {m['material']} keywords {want}") - variants_needed[m["material"]] = want - # read last: loading the UIManager bundles registers their shaders - ui_cam = ui_camera(ex) - - doc = { - "about": about(language, windows), - "language": language_doc(language, styles if game else None), - "frontCanvasOrder": front_order, - "widget": widget_rec, - "nodes": out_nodes, - "videoAndStillCamera": camera, - "uiCamera": ui_cam, - "videoAndStillScreenImage": screen_image, - "chatWidget": chat_widget, - "sprites": sprites_out, - "letterBoxSprite": lb_sprite["name"], - "textures": textures, - "materials": materials, - "materialKeywords": variants_needed, - } - if blur is not None: - doc["blur"] = blur - if game: - tmp = player.resource("TMP Settings") - tmp_settings = {k: player.names_in(tmp, v) for k, v in player.mono(tmp).items()} - doc["fonts"] = font_out - doc["tmpSettings"] = { - k: tmp_settings[k] - for k in ( - "m_fallbackFontAssets", - "m_defaultFontAsset", - "m_missingGlyphCharacter", - "m_matchMaterialPreset", - "m_enableExtraPadding", - "m_warningsDisabled", - ) - } - doc.update( - { - "clips": clips, - "controllers": controllers, - "transitions": transitions, - "playerSettings": { - k: ps[k] - for k in ( - "_defaultTransitionAssetAddress", - "_waitAfterVoiceTime", - "_waitTalkTextUnitTime", - "_minTalkDisplayTime", - "_isAdvViewportFollowOnResolutionChanged", - ) - }, - "dotween": { - k: dts[k] - for k in ( - "defaultEaseType", - "defaultUpdateType", - "defaultTimeScaleIndependent", - "timeScale", - "defaultEaseOvershootOrAmplitude", - "defaultEasePeriod", - ) - }, - "shaders": {"index": "shaders/shaders.json", "names": shader_summary["names"]}, - } - ) - if game: - doc["glyphCoverage"] = font_doc["coverage"] - if id_texts is not None: - doc["masterIdTexts"] = id_texts - doc["dialogs"] = dialogs - if chat: - doc["chatTexts"] = chat - if status_texts: - doc["chatStatusTexts"] = status_texts - doc["textStyle"] = styles.summary() - write_json(ui_dir / "ui.json", doc) - out = {"textures": {k: (v["width"], v["height"]) for k, v in textures.items()}, "shaders": shader_summary["names"]} - if not game: - return {**out, "fonts": "open", "texts": sum(1 for n in out_nodes if "textStyle" in n)} - return { - **out, - "coverage": font_doc["coverage"]["counts"], - "fonts": sorted(font_out), - "runtimeGlyphs": { - k: { - "generated": len(v["runtimeGlyphs"]["generated"]), - "validation": { - kk: v["runtimeGlyphs"]["validation"][kk] - for kk in ("glyphs", "maxAbsError", "meanAbsError", "exactGlyphs") - }, - } - for k, v in font_out.items() - if v["runtimeGlyphs"] - }, - } - - -def talk_windows(episode: dict) -> list[str]: - """The talk windows the story attaches, in the order of their nodes under TalkView: UIDefaultTalkWindow (AdvPlayer - PlayInitialCommands attaches it), then by name the other talk windows of the episode's resources (the TalkWindow - rows, adv.closure kind "talkwindow").""" - names = {r["address"][r["address"].rindex("/") + 1 :] for r in episode["resources"] if r["kind"] == "talkwindow"} - return [adv.DEFAULT_TALK_WINDOW] + sorted(names - {adv.DEFAULT_TALK_WINDOW}) - - -def window_key(cat, name: str) -> str: - """The prefab key of the talk window `name`: WINDOW_KEY for the default window, else the TalkWindow address - ("UI/Prefab/Parts/Adv/Talk/" + name) resolved as adv.closure resolves it (embedded content: the "Emb" key when - the catalog lacks the plain one).""" - if name == adv.DEFAULT_TALK_WINDOW: - return WINDOW_KEY - address = adv.TALK_WINDOW_PREFIX + name - return address if cat.has(address) else adv.EMBEDDED_PREFIX + address - - -def window_nodes(ex: Exporter, names: list[str]) -> list[dict]: - """The prefab nodes of the talk windows `names`, in that order, with their paths under TalkView - (AdvTalkView.SetWindow parents the attached window there at localPosition 0).""" - out = [] - for name in names: - nodes = ex.prefab(window_key(ex.cat, name))["nodes"] - if not nodes or nodes[0]["path"] != name: - raise RuntimeError(f"talk window {name}: the prefab root is not {name}") - out += [{**w, "path": f"{WINDOW_PARENT}/{w['path']}"} for w in nodes] - return out - - -def uses_backdrop_filter(window: list[dict]) -> bool: - """Whether a talk window of the nodes (window_nodes) has UIAdvTalkWindow._useBackdropFilter set.""" - return any(c.get("class") == "UIAdvTalkWindow" and c["_useBackdropFilter"] for n in window for c in n["components"]) - - -def backdrop_path(widget: list[dict], comp: dict) -> str: - """The node of the centre-talk backdrop (UIAdvWidget BACKDROP_REFS: its CanvasGroup and Image, on one node).""" - paths = {_ref_path(comp[k]) for k in BACKDROP_REFS} - path = next(iter(paths)) if len(paths) == 1 else None - if path is None: - raise RuntimeError(f"UIAdvWidget {BACKDROP_REFS}: {sorted(map(str, paths))}") - node = next((n for n in widget if n["path"] == path), None) - if node is None: - raise RuntimeError(f"{path}: not a widget node") - if not any(c["type"] == "CanvasGroup" for c in node["components"]) or _component(node, ("Image",)) is None: - raise RuntimeError(f"{path}: no CanvasGroup or Image") - return path - - -def blur_record(graphics: dict) -> dict: - """blur: the UIRendererFeature of renderer 0 (UIBlurController.ExecBlur -> UIRenderPass CaptureAndBlur): its - Dual Kawase iterations, offset, downsample, blend rate maximum and shader name, read from the player data - (renderer 0 of every pipeline must be the same renderer).""" - first = {p["m_RendererDataList"][0] for p in graphics["pipelines"].values()} - if len(first) != 1: - raise NotImplementedError(f"renderer 0 differs between the pipelines: {sorted(first)}") - name = advscene.camera_renderer(graphics, graphics["defaultPipeline"], 0) - feats = [f for f in graphics["renderers"][name]["m_RendererFeatures"] if f["class"] == BLUR_FEATURE] - if len(feats) != 1: - raise RuntimeError(f"renderer {name}: {len(feats)} {BLUR_FEATURE}") - f = feats[0] - return { - "renderer": name, - "active": bool(f["m_Active"]), - "iterations": f["_blurIterations"], - "offset": f["_blurOffset"], - "downsample": f["_blurDownsample"], - "blendRateMax": f["_blurBlendRateMax"], - "shader": f["_dualKawaseBlurShader"], - } - - -def blur_settings(player: PlayerData) -> tuple[dict, object]: - """ui.json `blur` and its Shader object, for a backdrop-filter talk window (UIAdvWidget.UpdateAdvBlurAndBackdrop -> - UIManager.UseBlur): the UIRendererFeature of renderer 0 (blur_record; CameraManager and UICameraController set - renderer 0 on the main, sub and UI cameras, and the UI overlay camera runs the pass).""" - blur = blur_record(player.graphics()) - if not blur["active"]: - raise NotImplementedError(f"renderer {blur['renderer']}: the UIRendererFeature is not active") - objs = [o for o in player.renderer_shaders(blur["renderer"]) if o.read().m_ParsedForm.m_Name == blur["shader"]] - if len(objs) != 1: - raise RuntimeError(f"renderer {blur['renderer']}: {len(objs)} shaders {blur['shader']}") - return blur, objs[0] - - -def pictogram_catalogs(nodes: list) -> dict: - """{catalog name: its serialized record} of the UIPictogram sprite catalogs the prefab nodes name (the exporter - writes an asset in full at its first reference).""" - out = {} - for n in nodes: - for c in n["components"]: - cat = c.get("_spriteCatalog") if c.get("class") == "UIPictogram" else None - if cat and "_spritesByKey" in cat: - out.setdefault(cat["name"], cat) - return out - - -def resolve_pictograms(rec: dict, catalogs: dict) -> None: - """The sprite of each UIPictogram record of a node (UISpriteCatalogBase.GetSprite: the catalog's sprite of the - key, `sprite`; null when the catalog has none) and the catalog's default sprite (`defaultSprite`).""" - for p in (rec.get("behaviours") or {}).get("UIPictogram", ()): - cat = catalogs.get(p["catalog"]) - if cat is None: - raise RuntimeError(f"{rec['path']}: sprite catalog {p['catalog']} not exported") - by_key = {e["Key"]: e["Value"] for e in cat["_spritesByKey"]["_list"]} - hit, default = by_key.get(p["_key"]), cat.get("_defaultSprite") - p["sprite"] = hit["name"] if hit else None - p["defaultSprite"] = default["name"] if default else None - p["_spriteRef"] = hit["spriteRef"] if hit else None - p["_defaultRef"] = default["spriteRef"] if default and not hit else None - - -def widget_canvases(widget: list, comp: dict) -> dict: - """widget: UIWidget._canvasSortOrder and the sorting order of each canvas of UIWidget._canvases, in list order - (UIWidget.TrueCanvasSortOrder reads the first).""" - by_path = {n["path"]: n for n in widget} - canvases = [] - for ref in comp["_canvases"]: - path = _ref_path(ref) - hit = [c for c in (by_path.get(path) or {"components": []})["components"] if c["type"] == "Canvas"] - if len(hit) != 1: - raise RuntimeError(f"_canvases: {path} has {len(hit)} Canvas components") - canvases.append({"path": path, "sortingOrder": hit[0]["m_SortingOrder"]}) - return {"canvasSortOrder": comp["_canvasSortOrder"], "canvases": canvases} def emoji_sequence_key(name: str, unicode: int) -> str | None: @@ -1108,19 +56,10 @@ def emoji_sequence_key(name: str, unicode: int) -> str | None: return "".join(out) -def sprite_asset_record(ex: Exporter, key: str = EMOJI_SPRITE_ASSET_KEY) -> dict: - """The TMP sprite asset at `key` (LocalizeManager's emoji sprite asset by default) as the text layout reads it: - `name`, `faceInfo` (m_FaceInfo), `characters` (m_SpriteCharacterTable in order: `index`, `unicode`, `name`, - `glyph`, `scale`), `glyphs` ({glyph index: metrics, rect (in the sprite sheet), scale, atlasIndex}), - `sequences` (the entries of the legacy sprite list, spriteInfoList, whose name holds a '-', in order: `name`, - `unicode`; TMP_EmojiSearchEngine builds its sequence table from them), `sheet` (the sprite sheet's name, width, - height, mip count and texture settings) and `material` (its material record). Fallback sprite assets are not - implemented.""" - o = ex.key_object(key) - tt = o.read_typetree() +def sprite_record(tt: dict, sheet: dict, material: dict) -> dict: + """The text layout's sprite asset record from its fields, sheet metadata and material.""" if tt.get("fallbackSpriteAssets"): - raise NotImplementedError(f"{key}: fallback sprite assets") - sheet = ex.referent(o, tt["spriteSheet"]).read_typetree() + raise NotImplementedError(f"{tt['m_Name']}: fallback sprite assets") chars = [ {"index": i, "unicode": c["m_Unicode"], "name": c["m_Name"], "glyph": c["m_GlyphIndex"], "scale": c["m_Scale"]} for i, c in enumerate(tt["m_SpriteCharacterTable"]) @@ -1154,7 +93,7 @@ def sprite_asset_record(ex: Exporter, key: str = EMOJI_SPRITE_ASSET_KEY) -> dict "mipCount": sheet.get("m_MipCount", 1), "settings": sheet.get("m_TextureSettings"), }, - "material": ex.material(ex.deref(o, tt["m_Material"])), + "material": material, } @@ -1216,91 +155,6 @@ def emoji_sequence_names(table: dict[tuple, str], fast: set[tuple], text: str) - return out -def _camera(widget: list, path: str | None, field: str = "_videoAndStillCamera") -> dict: - """videoAndStillCamera: the Camera (CAMERA_FIELDS) and UniversalAdditionalCameraData (CAMERA_DATA_FIELDS) of the - node at `path` (UIAdvWidget._videoAndStillCamera; `field` names the reference in errors).""" - node = next((n for n in widget if n["path"] == path), None) - if node is None: - raise RuntimeError(f"{field}: no node {path}") - cam = [c for c in node["components"] if c["type"] == "Camera"] - data = [c for c in node["components"] if c.get("class") == "UniversalAdditionalCameraData"] - if len(cam) != 1 or len(data) != 1: - raise RuntimeError(f"{path}: {len(cam)} Camera, {len(data)} UniversalAdditionalCameraData") - return { - "path": path, - "active": node["active"], - "camera": {k: cam[0][k] for k in CAMERA_FIELDS}, - "additionalCameraData": {k: data[0][k] for k in CAMERA_DATA_FIELDS}, - } - - -def ui_camera(ex: Exporter) -> dict: - """uiCamera: the camera UICameraController._uiCamera of the UIManager prefab (UIManager._cameraController), with - its UniversalAdditionalCameraData (_uiCameraData, on the same node), as _camera's record. The components are read - from their typetrees, so nothing they reference is exported.""" - go = ex.key_object(UI_MANAGER_KEY) - if go is None or go.type.name != "GameObject": - raise RuntimeError(f"{UI_MANAGER_KEY}: container asset is {go and go.type.name}") - _, graph = ex.load(UI_MANAGER_KEY) - - def mono(owner, pptr, cls: str, what: str): - o = ex.deref(owner, pptr) if pptr else None - if o is None or o.type.name != "MonoBehaviour" or ex.script_class(o) != cls: - raise RuntimeError(f"{UI_MANAGER_KEY}: {what} is not a {cls}") - return o - - comps = [ex.deref(go, c["component"]) for c in go.read_typetree()["m_Component"]] - managers = [ - c for c in comps if c is not None and c.type.name == "MonoBehaviour" and ex.script_class(c) == "UIManager" - ] - if len(managers) != 1: - raise RuntimeError(f"{UI_MANAGER_KEY}: {len(managers)} UIManager on the prefab root") - ctrl = mono( - managers[0], - managers[0].read_typetree()["_cameraController"], - "UICameraController", - "UIManager._cameraController", - ) - ctrl_tt = ctrl.read_typetree() - cam = ex.deref(ctrl, ctrl_tt["_uiCamera"]) if ctrl_tt["_uiCamera"] else None - if cam is None or cam.type.name != "Camera": - raise RuntimeError(f"{UI_MANAGER_KEY}: UICameraController._uiCamera is not a Camera") - data = mono(ctrl, ctrl_tt["_uiCameraData"], "UniversalAdditionalCameraData", "UICameraController._uiCameraData") - cam_tt, data_tt = cam.read_typetree(), data.read_typetree() - go_pid = cam_tt["m_GameObject"]["m_PathID"] - if data_tt["m_GameObject"]["m_PathID"] != go_pid: - raise RuntimeError(f"{UI_MANAGER_KEY}: _uiCamera and _uiCameraData are on different GameObjects") - path = graph.path(graph.tf_of_go[go_pid]) - node = { - "path": path, - "active": bool(graph.go[go_pid]["m_IsActive"]), - "components": [ - {"type": "Camera", **cam_tt}, - {"type": "MonoBehaviour", "class": "UniversalAdditionalCameraData", **data_tt}, - ], - } - return _camera([node], path, "_uiCamera") - - -def master_texts(master: Path, ids, field: str) -> dict[str, str]: - """{MasterText id: its text in the text field `field`} of `ids`; a missing id raises.""" - from ..source import master as master_mod - - ids = set(ids) - rows = {r["_id"]: r for r in master_mod.table(Path(master), "MasterText") if r["_id"] in ids} - missing = sorted(ids - set(rows)) - if missing: - raise KeyError(f"text ids not in MasterText: {missing}") - return {i: rows[i].get(f"_{field}") or "" for i in sorted(ids)} - - -def master_id_texts(settings: dict, master: Path, field: str) -> dict: - """masterIdTexts: {AdvMasterIdSettings field of MASTER_ID_TEXTS: {id (MasterText id), text (in `field`)}}.""" - ids = {k: settings[k] for k in MASTER_ID_TEXTS} - texts = master_texts(master, ids.values(), field) - return {k: {"id": i, "text": texts[i]} for k, i in ids.items()} - - def _lookup_lang(lang: dict) -> dict: """`lang` (textstyle.language_fonts) with the Japanese font names of LocalizeManager.InitJapaneseFontLookUpTable: the Japanese font names, then its additional font names (the table TryGetFontIndex searches).""" @@ -1309,71 +163,6 @@ def _lookup_lang(lang: dict) -> dict: return {**lang, "languages": {**lang["languages"], textstyle.JAPANESE_MODE: table}} -def chat_localize(font_asset: str, material: str, lang: dict, mode: int) -> dict: - """LocalizeText.Init -> OnFontChanged of a text with _localizeEnabled whose font may be an additional font (the - chat windows' font): TryGetFontIndex = its index in the lookup table (_lookup_lang), the font of that index among - the language's loaded fonts (its font names, then the additional fonts LocalizeManager.LoadAdditionalAsset - appends; an index out of range: font 0), GetFontMaterial of the material type, ApplyLanguageLineSpacing. - -> {fontAsset, material, lineSpacing}.""" - table = [f"{n} SDF" for n in _lookup_lang(lang)["languages"][textstyle.JAPANESE_MODE]["fontNames"]] - if font_asset not in table: - raise NotImplementedError(f"font {font_asset} not in the Japanese font lookup table") - own = lang["languages"][mode] - fonts = [f"{n} SDF" for n in own["fontNames"] + own["additionalFontNames"]] - i = table.index(font_asset) - font = fonts[i] if i < len(fonts) else fonts[0] - mat_type = textstyle.material_type(material) - if mat_type not in lang["materialTypes"]: - raise NotImplementedError(f"material type {mat_type} not in LocalizeManager._materialTypeList") - return {"fontAsset": font, "material": f"{font[:-4]} - {mat_type}", "lineSpacing": LANGUAGE_LINE_SPACING[mode]} - - -def chat_windows(episode: dict) -> dict[str, str]: - """{window name: address} of the chat windows of the episode's resources ("Adv/Chat/Prefabs/" + name).""" - return { - r["address"][len(adv.CHAT_WINDOW_PREFIX) :]: r["address"] - for r in episode["resources"] - if r["kind"] == "chatwindow" - } - - -def chat_text_nodes(ex: Exporter, episode: dict): - """(window name, node, TMP component, LocalizeText component or None) of every TMP text of the episode's chat - windows, and the status text ids their AdvChatWindow components set (CHAT_STATUS_KEYS).""" - out, status = [], set() - for name, address in sorted(chat_windows(episode).items()): - for n in ex.prefab(address)["nodes"]: - win = _component(n, ("AdvChatWindow",)) - if win is not None: - status |= {win[k] for k in CHAT_STATUS_KEYS if win.get(k)} - comp = _component(n, TMP_CLASSES) - if comp is not None: - out.append((name, n, comp, _component(n, ("LocalizeText",)))) - return out, sorted(status) - - -def chat_texts(ex: Exporter, fonts, lang: dict, mode: int, episode: dict) -> tuple[dict, list[str]]: - """chatTexts {window: {node path: {textStyle, localizeText}}} of the episode's chat windows in the language `mode` - (the text record of textstyle.text_style with the font and material chat_localize gives, their settings from - `fonts` (fontsettings.FontSettings); font roles are the slots of the lookup table) and the status text ids of the - windows.""" - table_lang, styles, out = _lookup_lang(lang), {}, {} - nodes, status = chat_text_nodes(ex, episode) - for name, n, comp, loc in nodes: - fa, mat = comp["m_fontAsset"]["name"], comp["m_sharedMaterial"]["material"] - if loc and loc["m_Enabled"] and loc["_localizeEnabled"]: - loc_font = chat_localize(fa, mat, lang, mode) - fa, mat = loc_font["fontAsset"], loc_font["material"] - if (fa, mat) not in styles: - face = fonts.font(fa)["faceInfo"] - styles[(fa, mat)] = textstyle.material_style(fonts.material(fa, mat), face["m_PointSize"], face["m_Scale"]) - out.setdefault(name, {})[n["path"]] = { - "textStyle": textstyle.text_style(comp, loc, table_lang, styles[(fa, mat)], mode), - "localizeText": {"enabled": loc["m_Enabled"], "localizeEnabled": loc["_localizeEnabled"]} if loc else None, - } - return out, status - - def _number(v) -> float | None: try: return float(v) @@ -1400,132 +189,3 @@ def chat_runtime_texts(episode: dict) -> list[str]: elif c["cmd"] in ("ChatTalk", "ChatStamp", "ChatRead") and p1 is not None and p1 == int(p1) and p1 >= 1: out.append(f" {int(p1)}") return out - - -def about(language: str, windows: Sequence[str] = (adv.DEFAULT_TALK_WINDOW,)) -> str: - """ui.json `about`: what the file holds (the widget and its talk windows), in which client language.""" - return f"ADV front canvas UI data (UIAdvWidget + {', '.join(windows)}), {language}" - - -def language_doc(language: str, styles: textstyle.TextStyles | None = None) -> dict: - """ui.json `language`: the LanguageMode and text field of `language`, with `styles` (fonts game) the - LocalizeManager font lists and the font swap, and the line spacing LocalizeText applies.""" - mode = languages.mode(language) - doc = {"mode": mode, "field": languages.column(language)[1:]} - if styles is not None: - doc.update({"fonts": styles.lang, "fontSwap": styles.swap}) - doc["lineSpacing"] = LANGUAGE_LINE_SPACING[mode] - return doc - - -def shown_texts(episode: dict, master_ids: dict, field: str) -> list[str]: - """The texts the episode shows in the text field `field` (e.g. "traditionalChinese"), tags stripped: Talk, - Location and Subtitles lines with their ruby readings (), the speaker names of the Talk rows - (TargetTextIDs; the unknown-speaker text for TargetStatus 1, joined names with the split text; none for - TargetStatus 2) and the title. `master_ids`: AdvMasterIdSettings.""" - lines = [] - for c in episode["commands"]: - if c["cmd"] in ("Talk", "Location", "Subtitles", "ChoiceSet") and c.get("lines"): - lines.append(c["lines"][field]) - lines += RUBY.findall(c["lines"][field] or "") - names = set() - for c in episode["commands"]: - if c["cmd"] == "Talk" and c.get("TargetName") and c.get("TargetStatus", 0) != 2 and c.get("AdvTextID"): - ids = list(c.get("TargetTextIDs") or []) - if c.get("TargetStatus", 0) == 1: - ids = [master_ids["_unknownCharacterNameTextId"]] - elif len(ids) > 1: - ids.append(master_ids["_splitCharacterNameTextId"]) - for i in ids: - row = episode["text"].get(i) - if row is None: - raise KeyError(f"speaker text id {i} not in the episode text shard") - names.add(row[field]) - for c in episode["commands"]: # chat rows: lines, window / sender names, stamp logs - if c["cmd"] not in CHAT_COMMANDS or c.get("IgnoreData"): - continue - if c.get("lines"): - lines.append(c["lines"][field]) - ids = list(c.get("TargetTextIDs") or []) - if c["cmd"] == "ChatStamp" and c.get("TargetAssetName"): # AdvChatStampCommand.GetStampLogText - ids.append(c["TargetAssetName"].rsplit("/", 1)[-1] + "_log") - for i in ids: - row = episode["text"].get(i) - if row is not None: - names.add(row[field]) - if any(c["cmd"] in CHAT_COMMANDS for c in episode["commands"]): - for k in ("_chatReadTextId", "_chatStampLogTextId"): - row = episode["text"].get(master_ids.get(k)) - if row is not None: - names.add(row[field]) - title = episode.get("title") - if not title: - raise RuntimeError("episode.json has no title lines (adv.extract)") - return [_strip_tags(s) for s in lines + sorted(names) + [title[field]]] + chat_runtime_texts(episode) - - -def _game_fonts( - ex: Exporter, - styles: textstyle.TextStyles, - episode: dict, - out_nodes: list, - field: str, - master_ids: dict, - ui_texts: Sequence[str] = (), -) -> tuple[dict, dict]: - """`--fonts game`: the localized font / material of every text node (`text.localized`), the TMP font assets, - the glyph coverage of the characters the episode shows (in the text field `field`; `master_ids`: - AdvMasterIdSettings; `ui_texts`: texts of the UI itself) and the text materials. - -> (materials, the inputs of tmpfont.export_fonts + coverage).""" - fonts = tmpfont.FontSet(ex) - used_text_nodes = [n for n in out_nodes if "text" in n] - primaries = {} - for n in used_text_nodes: - t = n["text"] - t["localized"] = textstyle.localize_text( - n["path"], t["fontAsset"], t["material"], styles.swap, styles.lang, styles.mode - ) - primaries[t["localized"]["fontAsset"]] = None - for fname in primaries: - primaries[fname] = fonts.by_key(fontsettings.font_dir(fname) + fname) - - # -- characters the episode shows ------------------------------------------ - shown = shown_texts(episode, master_ids, field) + list(ui_texts) - chars = sorted({ord(ch) for s in shown for ch in s}) - - primary = [primaries[p] for p in primaries] - if len(primary) != 1: - raise NotImplementedError(f"texts use {len(primary)} primary fonts") - primary = primary[0] - coverage, needed, runtime_units = tmpfont.glyph_coverage(fonts, primary, chars) - # the underline character from the primary asset itself (baked, or generated as its dynamic asset would) - kind, font = fonts.lookup(primary, UNDERLINE_CHARACTER) or (None, None) - in_asset = font is primary and kind in ("baked", "runtime") - if in_asset: - (needed if kind == "baked" else runtime_units).setdefault(primary.name, set()).add(UNDERLINE_CHARACTER) - coverage["underline"] = {"font": primary.name, "character": chr(UNDERLINE_CHARACTER), "inAsset": in_asset} - # the missing glyph (tmpfont.missing_glyph): its substitute from the asset that has it - tmpfont.check_glyph_variants(fonts, primary, shown) - mg = tmpfont.missing_glyph(fonts, primary, chars, tmpfont.missing_glyph_character(ex.player)) - if mg: - found = fonts.lookup(primary, mg["unicode"]) - if found is None: - raise RuntimeError(f"missing glyph U+{mg['unicode']:04X}: in no font asset of {primary.name}") - kind, f = found - if kind != "synthesized": - (needed if kind == "baked" else runtime_units).setdefault(f.name, set()).add(mg["unicode"]) - coverage["missingGlyph"] = [chr(u) for u in mg["characters"]] - - # -- materials --------------------------------------------------------------- - materials, text_materials = tmpfont.text_materials( - ex, fonts, [n["text"]["localized"] for n in used_text_nodes], {primary.name}, needed, runtime_units - ) - return materials, { - "fonts": fonts, - "primaries": {primary.name}, - "needed": needed, - "runtime": runtime_units, - "textMaterials": text_materials, - "coverage": coverage, - "missingGlyph": {primary.name: mg} if mg else {}, - } diff --git a/src/nnnotes/content/advvideo.py b/src/nnnotes/content/advvideo.py deleted file mode 100644 index ee0cfc0..0000000 --- a/src/nnnotes/content/advvideo.py +++ /dev/null @@ -1,132 +0,0 @@ -"""ADV videos: Movie / Clip VideoID -> the episode's -Video row -> `Cri/Video/<_assetName>` (a USM, usm.py) -> -/videos/.webm + videos/videos.json. - -An addressable key `Cri/Video/` depends on a small bundle holding the CriWare.Assets MonoBehaviour of the -movie (`movieInfo`: size, frame rate, frame count, codec, alpha / audio / subtitle stream counts; `assetInfo`) and on -the USM itself, stored as-is on the CDN like the cue sheets' raw data (VideoManager.Prepare hands it to CRI Mana). A -movie asset may instead hold the USM bytes itself (a CriSerializedBytesAssetImpl). -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -from ..core.jsonio import write_json -from ..formats.usm import AUDIO_BITRATE, VP9_ENCODE, adx_info, demux, hca_info, webm_reencoded, webm_streams, write_webm - -if TYPE_CHECKING: - from ..source.catalog import Catalog - -VIDEO_DIR = "videos" -INDEX = "videos/videos.json" -# movieInfo.codecType (CriMana.CodecType) -CODECS = {1: "sofdec.prime", 5: "h264", 9: "vp9"} - - -def usm_asset(cat: Catalog, key: str) -> tuple[dict, Path]: - """(the movie's CriWare.Assets MonoBehaviour typetree, the USM file) of a `Cri/Video/...` key.""" - import UnityPy - - from ..source.addressables import remote_path - - info, raw = None, None - for e in cat.dependencies(key): - if remote_path(e["internal_id"]) is None: - continue - bundle = cat.as_bundle(e) - if bundle is not None: - env = UnityPy.load(str(cat.fetch(bundle))) - for o in env.objects: - if o.type.name == "MonoBehaviour": - tt = o.read_typetree() - if "movieInfo" in tt: - info = tt - else: - raw = cat.fetch_raw(e) - if info is None or raw is None: - raise RuntimeError(f"{key}: movie asset or USM data not among the dependencies") - return info, raw - - -def export(cat: Catalog, key: str, dst: Path, cri_key: int, work: Path) -> dict: - """One USM -> dst (.webm, write_webm: VP9 copied, or re-encoded when the movie's video is not VP9 or has an alpha - stream; the first audio stream as Opus). Returns its record (movie info, the audio's source; `encode` when the - video was re-encoded).""" - from ..source.config import tool - - info, raw = usm_asset(cat, key) - mi = info["movieInfo"] - codec = CODECS.get(mi["codecType"], f"codecType {mi['codecType']}") - streams = demux(raw.read_bytes(), cri_key) - if mi["numAudioStreams"] and not any(s.kind == "audio" for s in streams): - raise RuntimeError(f"{key}: movie info lists an audio stream the USM does not carry") - hca = any(s.kind == "audio" and s.codec == "hca" for s in webm_streams(streams)) - picked = write_webm( - streams, dst, work, tool("ffmpeg", "ffmpeg"), cri_key, tool("vgmstream", "vgmstream-cli") if hca else None - ) - rec = { - "key": key, - "codec": codec, - "width": mi["width"], - "height": mi["height"], - "displayWidth": mi["dispWidth"], - "displayHeight": mi["dispHeight"], - "frameRate": [mi["framerateN"], mi["framerateD"]], - "frames": mi["totalFrames"], - "assetInfo": info.get("assetInfo"), - } - if webm_reencoded(picked): - rec["encode"] = { - "codec": "vp9", - "crf": int(VP9_ENCODE[VP9_ENCODE.index("-crf") + 1]), - "alpha": any(s.kind == "alpha" for s in picked), - } - audio = next((s for s in picked if s.kind == "audio"), None) - if audio is not None: - rec["audio"] = { - "codec": "opus", - "bitrate": AUDIO_BITRATE, - "source": adx_info(audio.data) if audio.codec == "adx" else hca_info(audio.data), - } - return rec - - -def extract(cat: Catalog, episode: dict, out_dir: Path, cri_key: int | None = None) -> dict | None: - """Every video of the episode's closure -> videos/.webm; videos/videos.json maps each VideoID of the - Movie / Clip rows to its file, its -Video row (`master`: size, autoStop, hasAudio) and the stream facts. - Returns the index, or None when the episode has no video.""" - from ..formats import cri - from . import adv - - keys = sorted(r["address"] for r in episode["resources"] if r["kind"] == "video") - if not keys: - return None - if cri_key is None: - if cat.apk is None: - raise RuntimeError("USM key: pass cri_key= or open the Catalog with apk=") - cri_key = cri.hca_key(cat.apk) - out_dir = Path(out_dir) - files: dict[str, dict] = {} - names: dict[str, str] = {} - for key in keys: - name = key.rsplit("/", 1)[-1] - if name in names: - raise RuntimeError(f"videos {names[name]} and {key} share the file name {name}") - names[name] = key - rel = f"{VIDEO_DIR}/{name}.webm" - files[key] = {"file": rel, **export(cat, key, out_dir / rel, cri_key, out_dir / VIDEO_DIR / "_work")} - videos = {} - for c in episode["commands"]: - vid = c.get("VideoID") - if c["cmd"] not in adv.VIDEO_COMMANDS or not vid or str(vid) in videos: - continue - row = episode["videos"].get(vid) or episode["videos"].get(str(vid)) - if not row: - continue - key = adv.VIDEO_ADDRESS.format(row["_assetName"]) - if key in files: - videos[str(vid)] = {"master": row, **files[key]} - index = {"videos": videos, "format": "WebM: VP9 as stored in the USM, audio as Opus"} - write_json(out_dir / INDEX, index) - return index diff --git a/src/nnnotes/content/live.py b/src/nnnotes/content/live.py index 9058fab..0865498 100644 --- a/src/nnnotes/content/live.py +++ b/src/nnnotes/content/live.py @@ -1,40 +1,24 @@ -"""One live (music + difficulty) -> a self-contained live directory, the data format ournotes-player reads. - - /score/ chart as shipped, converted runtime notes, master rows (score.py) - /audio// the live BGM cue sheet decoded per cue + cues.json + streams.json (score.py) - /audio/live-audio.json the live's sounds (BGM, note SE, live SE) with their CRI routing, and the - note SE / live SE cue sheets decoded next to the BGM (liveaudio.py) - /livescene/ scene graph, cameras, lane, LightWeight background, start timeline, - textures and shaders (livescene.py) - /livenotes/ note / line / effect prefabs, skins, clips, particle systems, - textures and shaders (livenotes.py) - /liveui/ the start canvas (music title, credits, difficulty) in the client language: - strings, text records or the game's fonts, sprites (liveui.py) - /live.json index of the above - -`options` (liveoptions.LiveOptions, from `--live-option`) adds the files of the option variants it offers: the -mirrored score (score.py, live.json `notesMirror`), the other note skins and effect sets, the bar line view and the -option tables (livenotes.py), the other note sound sets (liveaudio.py). Without it the directory holds what the -default options read. - -The band of the LightWeight background and of the start timeline is the band of the player's deck centre -(`SelfMemberList[2]`: LightWeightBackgroundLoadStep.d__3 reads its BandID, LiveResourceBandResolver.Resolve -its MemberCard's Character.Band; MasterMemberCard.get_BandID = Character._bandID). The preview has no deck: -`resolve_band` takes it from `--leader-card` / `--band`, else from DEFAULT_BAND_RULE. -""" +"""Live products assembled from chart objects, master records and shared part manifests.""" from __future__ import annotations -from pathlib import Path +import gzip -from ..core.jsonio import write_json -from ..formats.encoders import FLAC_LEVEL -from ..source.catalog import Catalog -from ..unity.player import PlayerData -from . import liveaudio, livenotes, livescene, liveui, score -from .liveoptions import LiveOptions -from .score import master_table +from ..source import languages +from .prefabs import Objects, key_object +LIVE_FORMAT = 1 # live.json "format" +DIFFICULTIES = ("easy", "normal", "hard", "expert") # _easyID.._expertID -> file suffix _00.._03 +# the master tables of a music record (music_record) and of a live record (live_record) +MUSIC_RECORD_TABLES = ( + "MasterLiveMusic", + "MasterLiveMusicScore", + "MasterText", + "MasterBand", + "MasterSound", + "MasterSoundCueSheet", +) +LIVE_TABLES = (*MUSIC_RECORD_TABLES, "MasterCharacter") # Band of the preview when no deck centre is given: the band of the music's first vocal character # (MasterLiveMusic._vocalCharacterIDs[0] -> MasterCharacter._bandID). The music's own fields are not the game's input. DEFAULT_BAND_RULE = "firstVocalCharacter" @@ -46,15 +30,44 @@ BAND_KEYS = ("Band/{band}/live_stage/lightweight_background", "Band/{band}/timeline/live_start_playable_timeline") -def resolve_band( - cat: Catalog, master: Path, music_id: int, band: int | None = None, leader_card: int | None = None -) -> dict: - """The preview's band: {"band", "source", "note"} (recorded in livescene/scene.json slice.bandChoice).""" +def chart_key(file_name: str) -> str: + """LiveEntry loads `Live/MusicScore/` + MasterLiveMusicScore._musicScoreTextFileName.""" + return f"Live/MusicScore/{file_name}" + + +def jacket_key(name: str) -> str: + """The jacket sprite of MasterLiveMusic._jacketAssetName `name`.""" + return f"Image/Jacket/{name}" + + +def live_subject(music_id: int, difficulty: str, band: int | None = None) -> str: + """The subject of the live of a music's chart at `difficulty`, with the band `band` (else the default rule).""" + if difficulty not in DIFFICULTIES: + raise ValueError(f"{difficulty}: not a difficulty ({', '.join(DIFFICULTIES)})") + return f"{music_id}/{difficulty}" + (f"/{band}" if band is not None else "") + + +def parse_subject(subject: str) -> tuple[int, str, int | None]: + """(music id, difficulty, band or None) of a live subject (live_subject; ValueError for anything else).""" + parts = subject.split("/") + if len(parts) not in (2, 3) or not all(p.isdigit() for p in parts[::2]) or parts[1] not in DIFFICULTIES: + raise ValueError(f"{subject}: not a live subject, /[/]") + return int(parts[0]), parts[1], int(parts[2]) if len(parts) == 3 else None + + +def _rows(tables: dict, name: str) -> dict: + return {r["_id"]: r for r in tables[name]} + + +def band_choice(tables: dict, music_id: int, band: int | None = None, leader_card: int | None = None) -> dict: + """The preview's band {"band", "source", "note"} from master tables {name: rows}: the band of a leader card + (MasterMemberCard, MasterCharacter), the band given, else the band of DEFAULT_BAND_RULE (MasterLiveMusic, + MasterCharacter).""" if band is not None and leader_card is not None: raise ValueError("give either band or leader_card, not both") - chars = {r["_id"]: r for r in master_table(master, "MasterCharacter")} + chars = _rows(tables, "MasterCharacter") if leader_card is not None: - cards = [r for r in master_table(master, "MasterMemberCard") if r["_id"] == leader_card] + cards = [r for r in tables["MasterMemberCard"] if r["_id"] == leader_card] if len(cards) != 1: raise KeyError(f"MasterMemberCard {leader_card}: {len(cards)} rows") ch = chars.get(cards[0]["_characterID"]) @@ -70,12 +83,12 @@ def resolve_band( elif band is not None: b, source = band, "band given" else: - music = [r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id] + music = [r for r in tables["MasterLiveMusic"] if r["_id"] == music_id] if len(music) != 1: raise KeyError(f"MasterLiveMusic {music_id}: {len(music)} rows") vocals = music[0]["_vocalCharacterIDs"] if not vocals: - raise ValueError(f"music {music_id}: no vocal character; give --band or --leader-card") + raise ValueError(f"music {music_id}: no vocal character; give a band or a leader card") if vocals[0] not in chars: raise KeyError(f"music {music_id}: vocal character {vocals[0]} not in MasterCharacter") b = chars[vocals[0]]["_bandID"] @@ -83,98 +96,97 @@ def resolve_band( f"default ({DEFAULT_BAND_RULE}): MasterLiveMusic._vocalCharacterIDs[0] = {vocals[0]} -> " f"MasterCharacter._bandID" ) - b = int(b) - missing = [k.format(band=b) for k in BAND_KEYS if not cat.has(k.format(band=b))] - if missing: - raise KeyError(f"band {b}: {', '.join(missing)} not in the catalog") - return {"band": b, "source": source, "note": BAND_NOTE} + return {"band": int(b), "source": source, "note": BAND_NOTE} + + +def music_record(tables: dict, music_id: int) -> dict: + """MasterLiveMusic row, its 4 MasterLiveMusicScore rows, title / band name texts in every language + (languages.LANGUAGES) and sound rows, from master tables {name: rows}.""" + music = [r for r in tables["MasterLiveMusic"] if r["_id"] == music_id] + if len(music) != 1: + raise KeyError(f"MasterLiveMusic {music_id}: {len(music)} rows") + music = music[0] + scores, text, bands = (_rows(tables, n) for n in ("MasterLiveMusicScore", "MasterText", "MasterBand")) + sounds, sheets = _rows(tables, "MasterSound"), _rows(tables, "MasterSoundCueSheet") + + def tx(i): + r = text.get(i) + return None if r is None else {"id": i, **languages.texts(r)} + def snd(i): + r = sounds.get(i) + if r is None: + return None + return {"MasterSound": r, "MasterSoundCueSheet": sheets.get(r["_soundCueSheetID"])} -def index_doc(music_id: int, difficulty: str, score_summary: dict, audio: dict, live_audio: str) -> dict: - """live.json: `score_summary` from score.extract (its `notesMirror` when the score was also converted - mirrored), `audio` its BGM entry, `live_audio` the path of the live sounds (liveaudio.extract).""" - s = score_summary return { - "musicId": music_id, - "difficulty": difficulty, - "chart": s["chart"], - "notes": s["notes"], - **({"notesMirror": s["notesMirror"]} if "notesMirror" in s else {}), - "master": s["master"], - "audio": audio, - "liveAudio": live_audio, - "scene": "livescene/scene.json", - "noteAssets": "livenotes/notes.json", - "liveUi": "liveui/liveui.json", + "MasterLiveMusic": music, + "MasterLiveMusicScore": {d: scores.get(music[f"_{d}ID"]) for d in DIFFICULTIES}, + "title": tx(music["_titleTextID"]), + "bands": [ + {"MasterBand": bands.get(b), "name": tx(bands[b]["_nameTextID"]) if b in bands else None} + for b in music["_bandIDs"] + ], + "music": snd(music["_musicSoundID"]), + "jingle": snd(music["_jingleSoundID"]), } -def build( - cat: Catalog, - master: Path, - player: PlayerData, - music_id: int, - difficulty: str, - out_dir: Path, - audio_format: str = "flac", - band: int | None = None, - leader_card: int | None = None, - *, - language: str, - fonts: str = "open", - options: LiveOptions = LiveOptions(), - flac_level: int = FLAC_LEVEL, -) -> dict: - """One live directory. `language`: the client language of the start canvas (a languages.LANGUAGES code); - `fonts`: "open" or "game" (liveui.extract); `options`: the option variants whose files it carries as well - (liveoptions.resolve); `flac_level`: ffmpeg's compression level of the FLAC audio (cri.decode).""" - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - choice = resolve_band(cat, Path(master), music_id, band=band, leader_card=leader_card) - s = score.extract( - cat, - master, - music_id, - difficulty, - out_dir, - audio=True, - audio_fmt=audio_format, - flac_level=flac_level, - mirror=options.mirror, - ) - la = liveaudio.extract( - cat, - Path(master), - player, - music_id, - out_dir, - fmt=audio_format, # reuses the BGM decode - options=options, - flac_level=flac_level, - ) - sc = livescene.extract( - cat, player, out_dir, master=Path(master), music_id=music_id, band=choice["band"], band_choice=choice - ) - lu = liveui.extract( - cat, - player, - out_dir, - master=Path(master), - music_id=music_id, # reads livescene's shaders - difficulty=difficulty, - language=language, - fonts=fonts, +def live_record(tables: dict, subject: str) -> dict: + """What `live` reads of the master tables `tables` (LIVE_TABLES) for a live subject: the music's rows + (music_record), the difficulty and the band (band_choice). A difficulty the music has no chart of raises.""" + music_id, difficulty, band = parse_subject(subject) + music = music_record(tables, music_id) + if music["MasterLiveMusicScore"][difficulty] is None: + raise KeyError(f"music {music_id}: no {difficulty} chart (no MasterLiveMusicScore row)") + return {"music": music, "difficulty": difficulty, "band": band_choice(tables, music_id, band)} + + +def live_keys(record: dict) -> list[str]: + """The catalog keys of a live: its chart and its jacket.""" + music = record["music"] + score = music["MasterLiveMusicScore"][record["difficulty"]] + return sorted( + [chart_key(score["_musicScoreTextFileName"]), jacket_key(music["MasterLiveMusic"]["_jacketAssetName"])] ) - nt = livenotes.extract(cat, player, out_dir, master=Path(master), options=options) - index = index_doc(music_id, difficulty, s, s["audio"], la["index"]) - write_json(out_dir / "live.json", index) - return { - **index, - "band": choice, - "sceneSummary": sc, - "noteAssetSummary": nt, - "liveAudioSummary": la, - "liveUiSummary": lu, - "judgementNoteCount": s["judgementNoteCount"], - "fullComboCount": s["fullComboCount"], + + +def live(objects: Objects, record: dict, parts: dict) -> tuple[dict, bytes, dict[str, dict], set[str]]: + """(live.json, chart.json, {file: JSON document} of the converted charts, the object ids the player reads) of a + live (module documentation; `record`: live_record, `parts`: {part: manifest path}).""" + from .score import convert, load_bytes + + music, difficulty = record["music"], record["difficulty"] + row = music["MasterLiveMusic"] + score = music["MasterLiveMusicScore"][difficulty] + key = chart_key(score["_musicScoreTextFileName"]) + text = key_object(objects, key, "TextAsset") + raw = objects.bytes(text, "data") + root = load_bytes(raw) + source = { + "key": key, + "musicId": row["_id"], + "difficulty": difficulty, + "masterLiveMusicScoreId": score["_id"], + "fullComboCount": score["_fullComboCount"], + } + notes, mirror = ({**convert(root, mirror=m), "source": source} for m in (False, True)) + doc = { + "format": LIVE_FORMAT, + "musicId": row["_id"], + "difficulty": difficulty, + "band": record["band"], + "chart": "chart.json", + "notes": "notes.json", + "notesMirror": "notes.mirror.json", + "judgementNoteCount": notes["judgementNoteCount"], + "fullComboCount": score["_fullComboCount"], + "noteCount": len(notes["notes"]), + "lastNoteTimeMs": notes["lastNoteTimeMs"], + "master": music, + "jacket": key_object(objects, jacket_key(row["_jacketAssetName"]), "Sprite"), + "parts": dict(sorted(parts.items())), } + chart = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw + keep = {o for o in objects.ids() if o != text and objects.info(o).get("class") != "MonoScript"} + return doc, chart, {"notes.json": notes, "notes.mirror.json": mirror}, keep diff --git a/src/nnnotes/content/live2d.py b/src/nnnotes/content/live2d.py new file mode 100644 index 0000000..471b03c --- /dev/null +++ b/src/nnnotes/content/live2d.py @@ -0,0 +1,328 @@ +"""Live2D models assembled from the objects their prefab reaches: the player's model document and the Cubism SDK +interchange files. + +Both read a product's object table (`Objects`, pipeline.assemble), never a bundle. The model is the GameObject among +the table's roots (the object its catalog key names, Character/Live2D///model/); its components +are read from its prefab document (prefab.json), a MonoBehaviour's class being the last part of its script entry. + +`model` -> model.json (MODEL_FORMAT): + + name, key the model's id (model_id: the key's last part) and its key + prefab the object id of the model's GameObject + moc the object id of its CubismMoc (the root's CubismModel._moc); the moc3 is its file `blob:_bytes` + canvas the moc3's canvas (formats.cubism.moc3_canvas) + textures the object ids of the atlas pages the drawables draw with (CubismRenderer._mainTexture), by name + shaders {shader name: object id} of the shaders of the table (the drawables' materials', the mask + materials', ...) + resources {name: object id} of the materials the Cubism mask pass draws with (RESOURCES: Resources.Load + paths, each the Material among the roots named as the path's last part) + motionSync the root has the MotionSync controller and its CRI audio input (MOTION_SYNC_COMPONENTS); a model + without them takes its mouth in a story from the voice's lip sync analysis + bindings {clip object id: [{path, attribute}]}: the bindings of every AnimationClip of the table named in the + model's hierarchy, played from its root (unity.bindings.resolve) + +Of the objects, the player reads all but the MonoScripts; of a shader, its parsed form (`json`), its variant index +(`index`) and the programs it draws the materials with (formats.shader.player_program): those of each drawable +material's keywords, alone and with STORY_KEYWORDS, and, when a drawable is masked (MASK_KEYWORD), the mask +materials'. A shader no drawn material uses keeps its parsed form and index. + +`cubism` -> the Cubism SDK files other tools read: .moc3, textures/.png (the atlas pages, by name), +.physics3.json (CubismPhysicsController._rig; when the rig has sub-rigs), .exp3.json (each +CubismExpressionData), motions/.motion3.json (each CubismFadeMotionData, with the sample rate of the clip of +its name) and .model3.json (the files, the model's fade times of each motion, and the EyeBlink and LipSync +groups: the names of the GameObjects holding a CubismEyeBlinkParameter or a CubismMouthParameter). +""" + +from __future__ import annotations + +import re +from collections.abc import Iterator + +from ..core.jsonio import dumps +from ..formats import motion +from ..formats.cubism import expression_json, moc3_canvas, physics_json +from ..formats.shader import player_program +from ..unity import bindings +from .prefabs import Objects, component_class, ref_id, root_game_object, script_class + +__all__ = [ + "MASK_KEYWORD", + "LIVE2D_PREFIX", + "MODEL_FORMAT", + "MODEL_ID", + "MODEL_KEY", + "MOTION_SYNC_COMPONENTS", + "RESOURCES", + "STORY_KEYWORDS", + "Objects", + "cubism", + "model", + "model_id", + "model_keys", + "model_match", + "select_models", +] + +MODEL_FORMAT = 3 # model.json "format" +LIVE2D_PREFIX = "Character/Live2D/" +MODEL_KEY = re.compile(r"Character/Live2D/(?P[^/]+)/(?P[^/]+)/model/(?P=name)") +MODEL_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") +# Resources.Load paths of the materials the Cubism mask pass draws with +RESOURCES = { + "cubismMask": "Live2D/Cubism/Materials/Mask", + "cubismMaskCulling": "Live2D/Cubism/Materials/MaskCulling", +} +MASK_KEYWORD = "CUBISM_MASK_ON" # a drawable material's keyword: the drawable is masked +# keywords the story renderer adds to a character draw (_ADDITIONAL_LIGHTS_VERTEX: at quality 4); the mask pass draws +# with none +STORY_KEYWORDS = ("_ADDITIONAL_LIGHTS_VERTEX",) +# a model's MotionSync: both components on the prefab's root +MOTION_SYNC_COMPONENTS = ("CubismMotionSyncController", "Live2DMotionSyncCriAudioInput") +SHADER_ROLES = ("json", "index") # a shader's files the player reads besides its programs +_UNSAFE = re.compile(r"[^A-Za-z0-9_.-]+") + + +def model_match(key: str) -> re.Match[str]: + """The parts of a model key: group, name (ValueError for a key that is not a model's).""" + m = MODEL_KEY.fullmatch(key) + if m is None: + raise ValueError(f"{key}: not a Live2D model key (Character/Live2D///model/)") + return m + + +def model_id(key: str) -> str: + """The id of a model key: its (ValueError for a key that is not a model's, or a name that is not + URL-safe).""" + m = model_match(key) + if not MODEL_ID.fullmatch(m["name"]): + raise ValueError(f"{key}: model name {m['name']!r} is not URL-safe") + return m["name"] + + +def model_keys(keys) -> dict[str, str]: + """{id: key} of the model keys among `keys`, in id order; two keys with the same id raise.""" + out: dict[str, str] = {} + for k in keys: + if MODEL_KEY.fullmatch(k): + i = model_id(k) + if out.get(i, k) != k: + raise ValueError(f"model id {i}: keys {out[i]} and {k}") + out[i] = k + return dict(sorted(out.items())) + + +def select_models(specs, models: dict[str, str]) -> dict[str, str]: + """The models `specs` names (ids or keys; None: all) as {id: key} in id order, from `models` ({id: key}).""" + if specs is None: + return dict(models) + by_key = {k: i for i, k in models.items()} + out, unknown = {}, [] + for s in specs: + i = s if s in models else by_key.get(s) + if i is None: + unknown.append(s) + else: + out[i] = models[i] + if unknown: + raise ValueError( + f"not a Live2D model of the catalog: {', '.join(unknown)} " + f"(give a model id or a key Character/Live2D///model/)" + ) + return dict(sorted(out.items())) + + +class _Model: + """A model's GameObject, prefab document and components.""" + + def __init__(self, objects: Objects, key: str): + self.objects, self.key, self.name = objects, key, model_id(key) + self.id = root_game_object(objects, key) + self.prefab = objects.json(self.id) + self.root = self.prefab["nodes"][0] + + def components(self, cls: str, root_only: bool = False) -> Iterator[tuple[dict, dict]]: + """(node, component) of every component of class `cls` (of the root node only).""" + for n in [self.root] if root_only else self.prefab["nodes"]: + for c in n["components"]: + if component_class(c) == cls: + yield n, c + + def one(self, cls: str) -> dict: + found = [c for _, c in self.components(cls, root_only=True)] + if len(found) != 1: + raise ValueError(f"{self.key}: {len(found)} {cls} on the model's root") + return found[0] + + def moc(self) -> str: + oid = ref_id(self.one("CubismModel")["fields"].get("_moc")) + if oid is None or oid not in self.objects: + raise ValueError(f"{self.key}: the CubismModel's moc is not in the object table") + return oid + + def moc_bytes(self) -> bytes: + return self.objects.bytes(self.moc(), "blob:_bytes") + + def textures(self) -> list[str]: + """The atlas pages the drawables draw with, by name and object id.""" + pages = {ref_id(c["fields"].get("_mainTexture")) for _, c in self.components("CubismRenderer")} + pages.discard(None) + return sorted((o for o in pages if o is not None), key=lambda o: (self.objects.info(o).get("name") or "", o)) + + def drawable_materials(self) -> list[str]: + """The first material of every drawable's MeshRenderer.""" + out = [] + for _, c in self.components("MeshRenderer"): + mats = c["fields"].get("m_Materials") or [] + oid = ref_id(mats[0]) if mats else None + if oid is not None: + out.append(oid) + return out + + def scripted(self, cls: str) -> list[str]: + """The MonoBehaviours of the table whose script class is `cls` (outside the prefab: data assets).""" + return [o for o in self.objects.ids("MonoBehaviour") if script_class(self.objects, o) == cls] + + +def _resources(m: _Model) -> dict[str, str]: + out = {} + for name, path in RESOURCES.items(): + leaf = path.rsplit("/", 1)[-1] + hits = [ + o + for o in m.objects.roots + if o in m.objects and m.objects.info(o).get("class") == "Material" and m.objects.info(o).get("name") == leaf + ] + if len(hits) != 1: + raise ValueError(f"{m.key}: {len(hits)} Materials {leaf!r} among the roots (Resources.Load {path})") + out[name] = hits[0] + return out + + +def model(objects: Objects, key: str) -> tuple[dict, set[str], dict[str, set[str]]]: + """(model.json, the object ids the player reads, {shader id: the roles of its files it reads}) of the model + `key` (module documentation).""" + m = _Model(objects, key) + moc = m.moc() + resources = _resources(m) + sets: dict[str, set[tuple]] = {} # shader id -> keyword sets + drawn = [objects.json(o) for o in m.drawable_materials()] + for mat in drawn: + sid = ref_id(mat["shader"]) + if sid is None: + raise ValueError(f"{key}: material {mat['material']!r} has no shader") + kws = tuple(mat["keywords"]) + sets.setdefault(sid, set()).update({kws, (*kws, *STORY_KEYWORDS)}) + masked = any(MASK_KEYWORD in mat["keywords"] for mat in drawn) + for oid in resources.values(): + mat = objects.json(oid) + sid = ref_id(mat["shader"]) + if sid is None: + raise ValueError(f"{key}: material {mat['material']!r} has no shader") + sets.setdefault(sid, set()) + if masked: + sets[sid].add(tuple(mat["keywords"])) + for sid in objects.ids("Shader"): + sets.setdefault(sid, set()) # a shader no drawn material uses: its parsed form and index only + shaders, roles = {}, {} + for sid, kw_sets in sorted(sets.items()): + index = objects.json(sid, "index") + shaders[index["name"]] = sid + keep = set(SHADER_ROLES) + for kws in sorted(kw_sets): + try: + keep.add(player_program(index["variants"], kws)["role"]) + except ValueError as e: + raise ValueError(f"{key}: shader {index['name']}: {e}") from None + roles[sid] = keep + classes = {component_class(c) for c in m.root["components"]} + + def material(ref) -> dict | None: + oid = ref_id(ref) + return objects.json(oid) if oid is not None and oid in objects else None + + clips = {} + for cid in objects.ids("AnimationClip"): + if "json" in (objects.info(cid).get("files") or {}): + clips[cid] = bindings.resolve(objects.json(cid)["bindings"], m.prefab, m.root["path"], material) + doc = { + "format": MODEL_FORMAT, + "name": m.name, + "key": key, + "prefab": m.id, + "moc": moc, + "canvas": moc3_canvas(m.moc_bytes()), + "textures": m.textures(), + "shaders": dict(sorted(shaders.items())), + "resources": resources, + "motionSync": all(k in classes for k in MOTION_SYNC_COMPONENTS), + "bindings": clips, + } + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + return doc, keep, roles + + +def _json(doc, indent: int | None = None) -> bytes: + return dumps(doc, indent=indent, ensure_ascii=False).encode("utf-8") + + +def _page_files(m: _Model) -> dict[str, str]: + """{object id: path} of the atlas pages: textures/., a name two pages share numbered after it.""" + out, used = {}, set() + for oid in m.textures(): + info = m.objects.info(oid) + stem = _UNSAFE.sub("_", info.get("name") or "texture") + ext = (info.get("files") or {}).get("image", {}).get("ext", "png") + path, n = f"textures/{stem}.{ext}", 1 + while path.casefold() in used: + n += 1 + path = f"textures/{stem}-{n}.{ext}" + used.add(path.casefold()) + out[oid] = path + return out + + +def cubism(objects: Objects, key: str) -> dict[str, bytes]: + """{path: bytes} of the Cubism SDK files of the model `key` (module documentation).""" + m = _Model(objects, key) + name = m.name + files = {f"{name}.moc3": m.moc_bytes()} + pages = _page_files(m) + for oid, path in pages.items(): + files[path] = objects.bytes(oid, "image") + refs: dict = {"Moc": f"{name}.moc3", "Textures": list(pages.values())} + controllers = [c for _, c in m.components("CubismPhysicsController", root_only=True)] + rig = controllers[0]["fields"].get("_rig") if controllers else None + if rig and rig.get("SubRigs"): + refs["Physics"] = f"{name}.physics3.json" + files[refs["Physics"]] = _json(physics_json(rig)) + expressions = [] + for oid in m.scripted("CubismExpressionData"): + tt = objects.json(oid) + ename = tt["m_Name"].removesuffix(".exp3") + files[f"{ename}.exp3.json"] = _json(expression_json(tt)) + expressions.append({"Name": ename, "File": f"{ename}.exp3.json"}) + if expressions: + refs["Expressions"] = sorted(expressions, key=lambda e: e["Name"]) + rates = {} + for cid in objects.ids("AnimationClip"): + if "json" in (objects.info(cid).get("files") or {}): + clip = objects.json(cid) + rates[clip["name"]] = float(clip["sampleRate"]) + motions = {} + for oid in m.scripted("CubismFadeMotionData"): + tt = objects.json(oid) + mname = motion.motion_name(tt) + files[f"motions/{mname}.motion3.json"] = _json(motion.convert_fade(tt, rates.get(mname))) + entry = {"File": f"motions/{mname}.motion3.json"} + for k, field in (("FadeInTime", "ModelFadeInTime"), ("FadeOutTime", "ModelFadeOutTime")): + if tt[field] >= 0: + entry[k] = tt[field] + motions[mname] = [entry] + refs["Motions"] = dict(sorted(motions.items())) + groups = [] + for group, cls in (("EyeBlink", "CubismEyeBlinkParameter"), ("LipSync", "CubismMouthParameter")): + ids = sorted(n["name"] for n, _ in m.components(cls)) + if ids: + groups.append({"Target": "Parameter", "Name": group, "Ids": ids}) + files[f"{name}.model3.json"] = _json({"Version": 3, "FileReferences": refs, "Groups": groups}, indent=1) + return files diff --git a/src/nnnotes/content/liveaudio.py b/src/nnnotes/content/liveaudio.py index 07e7e32..9b6f193 100644 --- a/src/nnnotes/content/liveaudio.py +++ b/src/nnnotes/content/liveaudio.py @@ -1,47 +1,34 @@ -"""Live sounds for one music: SE / cheer / voice cue sheets, their CRI cue data and the live-audio index. - -`extract(cat, master, player, music_id, out_dir)` decodes every cue sheet the live plays in the preview slice -(auto play at Perfect, fresh profile, default options) with `cri.decode` into `/audio//` -and writes `/audio/live-audio.json`: - - sounds MasterSound id -> {row, sheet, cue, categories, volume, busSends, layers[{file, sampleRate, samples, - loopStart, loopEnd, loopFlag, volume, busSends}], lengthMs}; the cue structure (sequence -> tracks -> - synth -> waveform) is read from the ACB (nnnotes.formats.acb; command 65 categories, 146 volume, 111 bus - send) - categories CRI category volumes for a fresh profile (SoundVolumeSettings x AppConfigDefaultData) - react the ACF's REACT (ducking) entries between live categories - music the BGM sound id and PlayMusic volume - noteSe LiveNoteSeType -> sound id / volume / mute for the default preset (LiveSettingCreator.CreateSESettings); - with liveoptions.LiveOptions offering note sound sets also `groups`: set id -> LiveNoteSeType -> - sound id (MasterLiveNoteSe), every offered set's sounds decoded as well - liveSe LiveSeType -> sound id (MasterLiveSe); `sounds` has those of LIVE_SE_SOUNDS: the start and finish - cheers and the finish direction of every result (the one a live ends with depends on how it is played) - timeline intro / end timings the runtime needs - voice the start / finish character voice rule and the voice character the caller chose (none by default) - -Cue sheets come through `cri` (any of its layouts; the note SE / live SE / voice sheets are embedded ACBs); -each cue layer maps to its vgmstream stream by `streams.json` (memory AWB order). A sheet already decoded into -`/audio//` (the BGM by score.py) is reused. -""" +"""Music and live sound product records from explicit master data, cue sheets and player settings.""" from __future__ import annotations import struct -from pathlib import Path -from ..core.jsonio import write_json -from ..formats import cri from ..formats.acb import commands as _commands from ..formats.acb import tables as _tables from ..formats.acb import u16s as _u16s -from ..formats.encoders import FLAC_LEVEL -from ..source.apkset import ApkSet -from ..source.catalog import Catalog -from ..unity.player import PlayerData -from .liveoptions import LiveOptions -from .score import master_table ACF_IN_APK = "assets/Cri/Sound/Sirius.acf" +MUSIC_FORMAT, SOUNDS_FORMAT = 1, 1 # the "format" of music_doc / sounds_doc +MUSIC_TABLES = ("MasterLiveMusic", "MasterSound", "MasterSoundCueSheet") +SOUND_TABLES = ("MasterOptionDefault", "MasterLiveNoteSe", "MasterLiveSe", "MasterSound", "MasterSoundCueSheet") +# the MonoBehaviours of the sound settings (script classes; the APK's built-in files hold one of each) +SETTINGS_SCRIPTS = ("SoundVolumeSettings", "AppConfigDefaultData") +# the AppConfigDefaultData field of each live category's volume (AppConfig.UpdateLiveSoundVolumeConfig) +LIVE_VOLUMES = { + "LiveBgm": "LiveBgmSoundVolume", + "LiveSe": "LiveSeSoundVolume", + "LiveVoice": "LiveVoiceSoundVolume", + "LiveNotesSe": "LiveNotesSeSoundVolume", +} +SE_PATTERN = "NoteSePatternId" # the option of the note sound sets (liveoptions) +# intro / end timings of the live's sounds +TIMELINE = { + "startCheerDelaySec": 1.0, + "playVoiceSignalSec": 1.483333, + "startCheerStopFadeSec": 1.0, + "finishCheerStopFadeSec": 1.0, +} # App.Master.LiveNoteSeType / LiveSeType NOTE_SE_TYPES = { @@ -201,10 +188,9 @@ def cmd(table, index): return out -def acf_info(apk: Path) -> dict: - """Categories, buses and REACT entries of the game's ACF (`assets/Cri/Sound/Sirius.acf` in the APK).""" - with ApkSet(apk) as z: - top, T = _tables(z.read(ACF_IN_APK)) +def acf_info(acf: bytes) -> dict: + """Categories, buses and REACT entries of the game's ACF (ACF_IN_APK).""" + top, T = _tables(acf) cat_names = {r["Index"]: r["Name"] for r in T["CategoryNameTable"]} cats = {} for r in T["CategoryTable"]: @@ -234,40 +220,36 @@ def acf_info(apk: Path) -> dict: # --------------------------------------------------------------------------- game settings -def _player_mono(player: PlayerData, cls: str) -> dict: - hits = [] - for o in player.env.objects: - if o.type.name != "MonoBehaviour": - continue - try: - if player.script(o)[2] != cls: - continue - except Exception: # MonoBehaviours without a resolvable script are not ours - continue - hits.append(player.mono(o)) - if len(hits) != 1: - raise RuntimeError(f"{cls}: {len(hits)} objects in the player data") - return hits[0] - - -def category_volumes(player: PlayerData) -> dict: - """Fresh-profile CRI category volumes: `` = SoundVolumeSettings.DefaultVolume x 1.0 - (SoundManager.Boot ChangeVolume("All", 1)); `LiveConfig` = LocalCacheData live volume = AppConfigDefaultData +def category_volumes(svs: dict, acd: dict) -> dict: + """Fresh-profile CRI category volumes from the SoundVolumeSettings `svs` and AppConfigDefaultData `acd` + MonoBehaviours (SETTINGS_SCRIPTS): `` = SoundVolumeSettings.DefaultVolume x 1.0 (SoundManager.Boot + ChangeVolume("All", 1)); `LiveConfig` = LocalCacheData live volume = AppConfigDefaultData (AppConfig.UpdateLiveSoundVolumeConfig); Bgm/Se/VoiceConfig = system option 400-402 / 100 (preset 1: 1.0).""" - svs = {e["CategoryName"]: e["DefaultVolume"] for e in _player_mono(player, "SoundVolumeSettings")["_collection"]} - acd = _player_mono(player, "AppConfigDefaultData")["_defaultData"] - live = { - "LiveBgm": "LiveBgmSoundVolume", - "LiveSe": "LiveSeSoundVolume", - "LiveVoice": "LiveVoiceSoundVolume", - "LiveNotesSe": "LiveNotesSeSoundVolume", - } - out = dict(svs) - for cat, field in live.items(): - out[cat + "Config"] = acd[f"<{field}>k__BackingField"] + out = {e["CategoryName"]: e["DefaultVolume"] for e in svs["_collection"]} + defaults = acd["_defaultData"] + for cat, field in LIVE_VOLUMES.items(): + out[cat + "Config"] = defaults[f"<{field}>k__BackingField"] return out +def sound_settings(objects) -> tuple[dict, dict]: + """The SoundVolumeSettings and AppConfigDefaultData MonoBehaviours (SETTINGS_SCRIPTS) among the root objects of a + product's object table, by their script's class (exactly one of each).""" + found: dict[str, list] = {c: [] for c in SETTINGS_SCRIPTS} + for oid in objects.roots: + if oid not in objects or objects.info(oid).get("class") != "MonoBehaviour": + continue + doc = objects.json(oid) + cls = str(doc.get("$script") or "").rsplit("|", 1)[-1] + if cls in found: + found[cls].append(doc) + for cls, docs in found.items(): + if len(docs) != 1: + raise ValueError(f"{cls}: {len(docs)} objects among the product's roots") + svs, acd = (found[c][0] for c in SETTINGS_SCRIPTS) + return svs, acd + + def _option(rows: list[dict], preset: int, item: int) -> str: hit = [r["_valueString"] for r in rows if r["_presetId"] == preset and r["_optionItemType"] == item] if len(hit) != 1: @@ -275,10 +257,10 @@ def _option(rows: list[dict], preset: int, item: int) -> str: return hit[0] -def note_se_groups(master: Path) -> dict[int, dict[int, int]]: - """MasterLiveNoteSe: sound set (_groupID) -> LiveNoteSeType -> sound id.""" +def note_se_groups(rows: list[dict]) -> dict[int, dict[int, int]]: + """MasterLiveNoteSe rows: sound set (_groupID) -> LiveNoteSeType -> sound id.""" out: dict[int, dict[int, int]] = {} - for r in master_table(master, "MasterLiveNoteSe"): + for r in rows: out.setdefault(r["_groupID"], {})[r["_liveNoteSeType"]] = r["_seId"] return out @@ -291,13 +273,14 @@ def _volume(opt: list[dict], preset: int, item: int) -> float: return min(max(int(_option(opt, preset, item)) / 100.0, 0.0), 1.0) -def note_se_settings(master: Path, preset: int = 1, groups: tuple = ()) -> dict: - """LiveSettingCreator.CreateSESettings for a fresh profile (option preset `preset`): pattern mode takes the - MasterLiveNoteSe rows of the set NoteSePatternId (420); UseIndividualNoteSe (421) takes, per note SE type, the - row of the set its sound item names (BuildIndividualNoteSeDictionary; a type whose set has no row for it has no - entry). `groups`: sound set ids whose tables are written as `groups` as well (the sets a viewer may choose).""" - opt = master_table(master, "MasterOptionDefault") - table = note_se_groups(master) +def note_se_settings(tables: dict, preset: int = 1, groups: tuple = ()) -> dict: + """LiveSettingCreator.CreateSESettings for a fresh profile (option preset `preset`) from the master tables + `tables` {name: rows} (MasterOptionDefault, MasterLiveNoteSe): pattern mode takes the MasterLiveNoteSe rows of the + set NoteSePatternId (420); UseIndividualNoteSe (421) takes, per note SE type, the row of the set its sound item + names (BuildIndividualNoteSeDictionary; a type whose set has no row for it has no entry). `groups`: sound set ids + whose tables are written as `groups` as well (the sets a viewer may choose).""" + opt = tables["MasterOptionDefault"] + table = note_se_groups(tables["MasterLiveNoteSe"]) group = int(_option(opt, preset, 420)) individual = _option(opt, preset, 421).upper() == "TRUE" if individual: @@ -332,26 +315,77 @@ def note_se_settings(master: Path, preset: int = 1, groups: tuple = ()) -> dict: return out -# --------------------------------------------------------------------------- extraction -def _decode(cat: Catalog, sheet: str, out_dir: Path, fmt: str, flac_level: int) -> list[dict]: - """Decode a cue sheet into /audio// (reusing an existing decode) and return its stream list.""" - d = out_dir / "audio" / sheet - if not (d / "streams.json").exists(): - cri.decode(cat, sheet, d, fmt=fmt, flac_level=flac_level) - import json +# --------------------------------------------------------------------------- sounds +def sheet_key(sheet: str) -> str: + """The catalog key of a cue sheet.""" + return f"Cri/Sound/{sheet}" + + +def music_sound(tables: dict, music_id: int) -> tuple[dict, dict]: + """(MasterSound row, MasterSoundCueSheet row) of a music's BGM (MasterLiveMusic._musicSoundID).""" + music = [r for r in tables["MasterLiveMusic"] if r["_id"] == music_id] + if len(music) != 1: + raise KeyError(f"MasterLiveMusic {music_id}") + sound = _row(tables, "MasterSound", music[0]["_musicSoundID"]) + return sound, _row(tables, "MasterSoundCueSheet", sound["_soundCueSheetID"]) + + +def _row(tables: dict, table: str, row_id: int) -> dict: + hit = [r for r in tables[table] if r["_id"] == row_id] + if len(hit) != 1: + raise KeyError(f"{table} {row_id}: {len(hit)} rows") + return hit[0] + + +def live_sounds(tables: dict, groups: tuple = ()) -> dict: + """The live sounds of the default options with the note sound sets `groups` offered as well: {"noteSe": + note_se_settings, "liveSe": {LiveSeType: sound id}, "ids": the sound ids a live plays (the default note SE, the + live SE of LIVE_SE_SOUNDS, then the note SE of the other offered sets)}.""" + nse = note_se_settings(tables, groups=groups) + live_se = {r["_liveSeType"]: r["_seId"] for r in tables["MasterLiveSe"]} + ids = sorted(set(nse["types"].values())) + [live_se[t] for t in LIVE_SE_SOUNDS] + for se in sorted({v for g in nse.get("groups", {}).values() for v in g.values()}): + if se not in ids: + ids.append(se) + return {"noteSe": nse, "liveSe": live_se, "ids": ids} + + +def sheets_of(tables: dict, ids) -> dict[str, list[int]]: + """{cue sheet name: sound ids} of MasterSound ids, in the order of the ids.""" + sheets = {r["_id"]: r["_cueSheetName"] for r in tables["MasterSoundCueSheet"]} + out: dict[str, list[int]] = {} + for sid in ids: + out.setdefault(sheets[_row(tables, "MasterSound", sid)["_soundCueSheetID"]], []).append(sid) + return out - return json.loads((d / "streams.json").read_text(encoding="utf-8")) +def sound_record(tables: dict, ids) -> dict: + """The MasterSound and MasterSoundCueSheet rows of sound ids (sorted by id): what sound_entry reads of them.""" + sounds = sorted((_row(tables, "MasterSound", i) for i in set(ids)), key=lambda r: r["_id"]) + sheets = {r["_soundCueSheetID"] for r in sounds} + return { + "MasterSound": sounds, + "MasterSoundCueSheet": sorted((_row(tables, "MasterSoundCueSheet", i) for i in sheets), key=lambda r: r["_id"]), + } -def _sound_entry(row: dict, sheet: str, cue: dict, streams: list[dict], cue_name: str) -> dict: + +def sound_entry(row: dict, sheet: str, cues: dict, streams: list[dict], files=None) -> dict: + """The live-audio entry of a MasterSound row whose cue is in the sheet `sheet` (`cues`: acb_cues of its ACB, + `streams`: its decoded streams, streams.json): its categories, volume, bus sends and one layer per track with + the decoded stream (vgmstream streams follow the memory AWB order) at `audio//` (`files`: {stream + file: the layer's file name} where it differs).""" + if row["_isRandomPitch"]: + raise NotImplementedError(f"sound {row['_id']}: random pitch") + name = row["_cueName"] + cue = cues[name] layers = [] for L in cue["layers"]: - s = streams[L["awbId"]] # vgmstream streams follow the memory AWB order - if s["name"] != cue_name or abs(s["samples"] - L["samples"]) > 8: - raise RuntimeError(f"{sheet}/{cue_name}: AWB {L['awbId']} -> stream {s} does not match") + s = streams[L["awbId"]] + if s["name"] != name or abs(s["samples"] - L["samples"]) > 8: + raise RuntimeError(f"{sheet}/{name}: AWB {L['awbId']} -> stream {s} does not match") layers.append( { - "file": f"audio/{sheet}/{s['file']}", + "file": f"audio/{sheet}/{(files or {}).get(s['file'], s['file'])}", "stream": s["stream"], "awbId": L["awbId"], "sampleRate": s["sampleRate"], @@ -368,7 +402,7 @@ def _sound_entry(row: dict, sheet: str, cue: dict, streams: list[dict], cue_name return { "row": row, "sheet": sheet, - "cue": cue_name, + "cue": name, "category": row["_category"], "categories": cue["categories"], "volume": cue["volume"] * cue["acbVolume"], @@ -379,92 +413,94 @@ def _sound_entry(row: dict, sheet: str, cue: dict, streams: list[dict], cue_name } -def extract( - cat: Catalog, - master: Path, - player: PlayerData, - music_id: int, - out_dir: Path, - fmt: str = "flac", - voice_character: int | None = None, - options: LiveOptions = LiveOptions(), - *, - flac_level: int = FLAC_LEVEL, -) -> dict: - """Decode the live's sound cue sheets and write /audio/live-audio.json. Returns a summary. - voice_character: None = no character voice (the game picks the voice from the player's deck; there is no rule - without a deck). `options`: the note sound sets it offers are written as noteSe.groups, their sounds decoded - after the default ones. `flac_level`: ffmpeg's compression level of the FLAC files (cri.decode).""" - out_dir = Path(out_dir) - if cat.apk is None: - raise RuntimeError("liveaudio needs the Catalog opened with apk= (ACF, HCA key)") - sounds = {str(r["_id"]): r for r in master_table(master, "MasterSound")} - sheets = {r["_id"]: r["_cueSheetName"] for r in master_table(master, "MasterSoundCueSheet")} - music = [r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id] +def sound_entries(tables: dict, ids, sheet) -> tuple[dict, dict]: + """({sound id: sound_entry}, {cue sheet: (cues, streams)}) of sound ids; `sheet(name)` gives a cue sheet's + (ACB bytes, streams).""" + sounds = {r["_id"]: r for r in tables["MasterSound"]} + entries, read = {}, {} + for name, sids in sheets_of(tables, ids).items(): + acb, streams = sheet(name) + cues = acb_cues(acb) + read[name] = (cues, streams) + for sid in sids: + entries[str(sid)] = sound_entry(sounds[sid], name, cues, streams) + return entries, read + + +def music_doc(tables: dict, music_id: int, acb: bytes, streams: list[dict], ext: str) -> dict: + """The live.music document of a music (`tables`: music_record): its BGM sound (`music`: sound id, volume, start + time) and that sound's entry (`sounds`), each layer's file with the extension `ext` (".m4a": the web format).""" + row, sheet = music_sound(tables, music_id) + name = sheet["_cueSheetName"] + files = {s["file"]: s["file"].rsplit(".", 1)[0] + ext for s in streams} + return { + "format": MUSIC_FORMAT, + "musicId": music_id, + "music": {"soundId": row["_id"], "volume": 1.0, "startTimeSec": 0.0}, + "sounds": {str(row["_id"]): sound_entry(row, name, acb_cues(acb), streams, files)}, + } + + +def music_record(tables: dict, music_id: int) -> dict: + """What music_doc reads of the master tables for one music: its MasterLiveMusic row, its BGM's MasterSound and + MasterSoundCueSheet rows.""" + music = [r for r in tables["MasterLiveMusic"] if r["_id"] == music_id] if len(music) != 1: raise KeyError(f"MasterLiveMusic {music_id}") - music_sound = music[0]["_musicSoundID"] - nse = note_se_settings(master, groups=options.se_patterns) - live_se = {r["_liveSeType"]: r["_seId"] for r in master_table(master, "MasterLiveSe")} - want = [music_sound] + sorted(set(nse["types"].values())) + [live_se[t] for t in LIVE_SE_SOUNDS] - voice = { - "decision": "caller", - "character": voice_character, - "rule": "LotteryStartVoice: random deck member, random MasterLiveStartCharacterVoice row of that " - "character; finish voice from LotteryFinishVoice; no deck -> undefined", - "startVoiceSoundIds": [], + row, sheet = music_sound(tables, music_id) + return {"MasterLiveMusic": music, "MasterSound": [row], "MasterSoundCueSheet": [sheet]} + + +def sounds_subject(groups: tuple = ()) -> str: + """The live.sounds subject of the note sound sets offered (LiveOptions.se_patterns: () for the default alone).""" + return f"{SE_PATTERN}={','.join(map(str, sorted(groups)))}" if groups else "default" + + +def sounds_groups(subject: str) -> tuple: + """The note sound sets of a live.sounds subject (sounds_subject).""" + if subject == "default": + return () + name, eq, values = subject.partition("=") + try: + groups = tuple(int(v) for v in values.split(",")) if name == SE_PATTERN and eq else () + except ValueError: + groups = () + if not groups or list(groups) != sorted(set(groups)): + raise ValueError(f"{subject}: not a live sounds subject (default, {SE_PATTERN}=)") + return groups + + +def sounds_record(tables: dict, subject: str) -> dict: + """What sounds_doc reads of the master tables for a live.sounds subject: the option defaults of preset 1, the + note SE and live SE tables, and the MasterSound / MasterSoundCueSheet rows of its sounds.""" + sounds = live_sounds(tables, sounds_groups(subject)) + return { + "MasterOptionDefault": [r for r in tables["MasterOptionDefault"] if r["_presetId"] == 1], + "MasterLiveNoteSe": tables["MasterLiveNoteSe"], + "MasterLiveSe": tables["MasterLiveSe"], + **sound_record(tables, sounds["ids"]), } - if voice_character is not None: - rows = [ - r for r in master_table(master, "MasterLiveStartCharacterVoice") if r["_characterId"] == voice_character - ] - voice["startVoiceSoundIds"] = [r["_voiceSoundId"] for r in rows] - want += voice["startVoiceSoundIds"] - for se in sorted({v for g in nse.get("groups", {}).values() for v in g.values()}): - if se not in want: - want.append(se) - - by_sheet: dict[str, list[int]] = {} - for sid in want: - r = sounds[str(sid)] - by_sheet.setdefault(sheets[r["_soundCueSheetID"]], []).append(sid) - entries, decoded = {}, {} - for sheet, ids in by_sheet.items(): - acb, layout = cri.acb_data(cat, sheet) - cues = acb_cues(acb["acb"]) - streams = _decode(cat, sheet, out_dir, fmt, flac_level) - decoded[sheet] = {"layout": layout, "cues": len(cues), "streams": len(streams)} - for sid in ids: - r = sounds[str(sid)] - if r["_isRandomPitch"]: - raise NotImplementedError(f"sound {sid}: random pitch") - entries[str(sid)] = _sound_entry(r, sheet, cues[r["_cueName"]], streams, r["_cueName"]) - - acf = acf_info(cat.apk) - doc = { - "musicId": music_id, - "spec": "nnnotes.content.liveaudio", + + +def sounds_doc(tables: dict, subject: str, sheet, svs: dict, acd: dict, acf: bytes) -> dict: + """The live.sounds document (`tables`: sounds_record; `sheet(name)`: a cue sheet's (ACB bytes, streams); `svs`, + `acd`: the SETTINGS_SCRIPTS MonoBehaviours; `acf`: the ACF): every sound a live plays besides the BGM, the + category volumes, the ACF's buses and categories, REACT, the note SE and live SE tables and the intro / end + timings.""" + live = live_sounds(tables, sounds_groups(subject)) + entries, _ = sound_entries(tables, live["ids"], sheet) + info = acf_info(acf) + return { + "format": SOUNDS_FORMAT, "sounds": entries, - "categories": category_volumes(player), - "acf": {"name": acf["name"], "buses": acf["buses"], "categories": acf["categories"]}, - "react": acf["react"], - "music": {"soundId": music_sound, "volume": 1.0, "startTimeSec": 0.0}, - "noteSe": nse, - "liveSe": {str(k): v for k, v in sorted(live_se.items())}, + "categories": category_volumes(svs, acd), + "acf": {"name": info["name"], "buses": info["buses"], "categories": info["categories"]}, + "react": info["react"], + "noteSe": live["noteSe"], + "liveSe": {str(k): v for k, v in sorted(live["liveSe"].items())}, "liveSeNames": LIVE_SE_TYPES, - "timeline": { - "startCheerDelaySec": 1.0, - "playVoiceSignalSec": 1.483333, - "startCheerStopFadeSec": 1.0, - "finishCheerStopFadeSec": 1.0, - }, - "voice": voice, - "decoded": decoded, - } - write_json(out_dir / "audio" / "live-audio.json", doc) - return { - "index": "audio/live-audio.json", - "sheets": sorted(by_sheet), - "sounds": len(entries), - "musicSoundId": music_sound, + "timeline": TIMELINE, } + + +# --------------------------------------------------------------------------- extraction diff --git a/src/nnnotes/content/livenotes.py b/src/nnnotes/content/livenotes.py index 3ffdabc..3d35d62 100644 --- a/src/nnnotes/content/livenotes.py +++ b/src/nnnotes/content/livenotes.py @@ -1,46 +1,48 @@ -"""Live note / effect runtime data -> livenotes/notes.json + textures + shaders. - -Everything spawned or animated per note in the in-play note highway: -note view prefabs (tap, flick +left/right/direction, -trace, slide/slide_end/connection, guide, slide_combo(+skip), none), long-note -body prefabs (slide_line_view, guide_line_view), the pair line prefab, -the note skin asset (sprites per note type/width/tilt), the note-effect skin settings -of the NoteEffectId option and the lane-effect settings (set effect001) with every effect -prefab they reference (particle systems exported with all module data), the judgement text view and -the judgement / combo sprite assets, the combo animator, the note animation controllers -and clips they reference, and the option / master values the motion formulas read. -Only what the default options load: the effect set of NoteEffectId (307) at quality Middle -(the `Light` set is Low only, `NoteSkinLoadStep.GetLightQualityAddress`; the -`Simple` sets are not used with default options); no bar / skill line prefabs -(MeasureLineDisplay 109 / LiveSkillActivationPositionDisplay 309 FALSE). With liveoptions.LiveOptions -also: the other offered note skins (`noteSkins`, `settings.skins`), the other offered note effect sets -and, with LiveQuality Low offered, their `Light` variants where the catalog has them (in `assets`, -`settings.effects`), with MeasureLineDisplay offered the bar line view (`prefabs.bar_line_view`: the -LiveBarLineView prefab the live scene's LiveBarLineViewContainer instantiates, its _elementPrefab), and the -option tables (`settings.optionDefaults` / `optionRanges` of every item the player offers). - -Objects go through export.Exporter in its "livenotes" clip format (clips and animator -controllers in registries referenced by id; binding paths and attributes left as -"crc32:" until a pass names them), following references to AnimatorControllers -and SpriteAtlases. Clip binding paths that point into the Live scene's UI subtrees -are named from that scene (`add_path_source`), which is not exported here. -""" +"""Note, skin and effect products assembled from shared object tables and master records.""" from __future__ import annotations -from pathlib import Path - -import numpy as np +import re +from collections.abc import Iterable, Mapping + +from .liveoptions import OPTION_ITEM_TYPES, PLAYER_ITEMS +from .prefabs import ( + Objects, + Prefab, + clip_bindings, + component_class, + controller_clips, + has_json, + key_object, + mesh_roles, + prefab_ids, + ref_id, + shader_table, +) -from ..core.jsonio import write_json -from ..source.catalog import Catalog -from ..unity.export import Exporter -from ..unity.player import PlayerData -from .liveoptions import OPTION_ITEM_TYPES, PLAYER_ITEMS, LiveOptions -from .livescene import LANE_COUNT, LANE_VIEW -from .score import master_table +__all__ = [ + "EFFECT_KEY", + "FLICK_SKIN_UNITS", + "LANE_EFFECT_KEY", + "NOTE_PREFABS", + "NOTES_FORMAT", + "NOTES_TABLES", + "SKIN_KEY", + "animator_bindings", + "asset_name", + "default_assets", + "effect_settings_key", + "notes", + "notes_keys", + "notes_record", + "notes_settings", + "noteeffect", + "noteskin", + "noteskin_keys", + "skin_key", +] -# --- keys ------------------------------------------------------------------ +NOTES_FORMAT = 1 # notes.json, noteskin.json and noteeffect.json "format" NOTE_VIEWS = [ "tap", "flick", @@ -55,18 +57,38 @@ "slide_combo_skip", "none", ] -PREFABS = {f"{v}_note_view": f"EmbLive/Prefabs/LiveGame/{v}_note_view" for v in NOTE_VIEWS} -PREFABS.update( +NOTE_PREFABS = {f"{v}_note_view": f"EmbLive/Prefabs/LiveGame/{v}_note_view" for v in NOTE_VIEWS} +NOTE_PREFABS.update( { "slide_line_view": "EmbLive/Prefabs/LiveGame/slide_line_view", "guide_line_view": "EmbLive/Prefabs/LiveGame/guide_line_view", "pair_note_line": "EmbLive/Prefabs/LiveGame/PairNoteLine/LiveGamePairNoteView", "note_groove_loop": "EmbLive/Prefabs/LiveGame/Effect/note_groove_loop", - "judge_effect_view": "EmbLive/Prefabs/UILiveNoteJudgeEffectView", } ) +LANE_EFFECT = "effect001" +LANE_EFFECT_KEY = f"Effect/Live/LaneEffect/{LANE_EFFECT}/LiveLaneEffectAssetSettings" +# LiveAddressablePath.GetLiveNoteSkinAssetPath / GetLiveNoteEffectSkinAssetPath +SKIN_KEY = re.compile(r"Live/Note/(?P[^/]+)/LiveNoteSkinAsset") +EFFECT_KEY = re.compile(r"Effect/Live/NoteEffect/(?P[^/]+)/LiveNoteEffectAssetSettings") +NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*") # a skin or effect set name, as a manifest path segment +# the flick views and the LiveNoteSkinAsset field of the unit that sets up each one's arrow +FLICK_SKIN_UNITS = { + "flick_note_view": "FlickNoteAsset", + "flick_left_note_view": "LeftFlickNoteAsset", + "flick_right_note_view": "RightFlickNote", +} + +LANE_COUNT = 24 # LiveDataCreator.CreateBootData passes 0x18 +# LiveBootDataCreator.CreateViewData: arguments of LiveSettingCreator.CreateLaneViewSettings +LANE_VIEW = { + "size": (1920.0, 1080.0), + "topRange": 0.05, + "bottomRange": 1.0, + "judgementScreenBottomPosition": 2.24, + "topPosition": 0.0, +} -# --- defaults (fresh profile) ------------------------------------------------- # MasterOptionDefault preset 1 (CurrentPresetIndex 0 -> Preset1); item types from App.Options.OptionItemType. OPTION_ITEMS = { k: OPTION_ITEM_TYPES[k] @@ -104,6 +126,7 @@ 310, ) } +NOTE_ITEMS = dict(sorted({**OPTION_ITEMS, **{k: OPTION_ITEM_TYPES[k] for k in PLAYER_ITEMS}}.items())) LIVE_SETTING_KEYS = [ "note_speed_min", "note_speed_max", @@ -115,33 +138,44 @@ "slide_offset_max_note_lane_width", "note_overlap_lane_buffer", ] -DEFAULT_SKIN = "skin001" # MasterLiveNoteSkin._id 1 (NoteDesignId default 1) -DEFAULT_EFFECT = "effect001" # MasterLiveNoteEffectSkin._id 1 (NoteEffectId default 1) +NOTES_TABLES = ( + "MasterOptionDefault", + "MasterOptionRange", + "MasterLiveSettings", + "MasterLiveNoteSkin", + "MasterLiveNoteEffectSkin", +) + + +def skin_key(name: str) -> str: + """The note skin asset of MasterLiveNoteSkin._assetName `name` (LiveAddressablePath.GetLiveNoteSkinAssetPath).""" + return f"Live/Note/{name}/LiveNoteSkinAsset" -# Catalog key families exported whole (every key under the prefix): the note effect settings of the NoteEffectId -# option's set ({effect}; LiveAddressablePath.GetLiveNoteEffectSkinAssetPath) and the lane effect settings, whose -# set is always LANE_EFFECT (LaneEffectLoadStep.GetAssetPath passes that constant to -# LiveAddressablePath.GetLiveLaneEffectSkinAssetPath), with the effect prefabs they reference by GameObject -LANE_EFFECT = "effect001" -KEY_PREFIXES = [ - "Effect/Live/NoteEffect/{effect}/", - f"Effect/Live/LaneEffect/{LANE_EFFECT}/", -] -SINGLE_KEYS = [ - "EmbLive/Animation/Combo/UILiveCombo", # UIComboCounterView's animator controller - "Live/UiSpriteAssets/LiveComboSpriteAsset", - "Live/UiSpriteAssets/LiveJudgementSpriteAsset", -] +def effect_settings_key(effect: str) -> str: + """The note effect settings asset of effect set `effect` (LiveAddressablePath.GetLiveNoteEffectSkinAssetPath).""" + return f"Effect/Live/NoteEffect/{effect}/LiveNoteEffectAssetSettings" -def settings(master: Path | None, options: LiveOptions = LiveOptions()) -> dict: - """Option defaults and master values the note runtime reads; with `options` also the offered note skins - (`skins`: NoteDesignId -> MasterLiveNoteSkin._assetName) and effect sets (`effects`: NoteEffectId -> - MasterLiveNoteEffectSkin._assetName; also when only other qualities are offered, whose effect set name the - player derives from it), and with its option tables the items of PLAYER_ITEMS as well.""" - out: dict = { - "skin": DEFAULT_SKIN, - "effect": DEFAULT_EFFECT, + +def asset_name(name: str) -> str: + """A note skin or effect set name as a subject (ValueError for one that is not URL-safe).""" + if not NAME.fullmatch(name): + raise ValueError(f"{name!r}: not a note skin or effect set name") + return name + + +def notes_keys() -> list[str]: + """The catalog keys of the notes product: its prefabs and the lane effect settings.""" + return sorted({*NOTE_PREFABS.values(), LANE_EFFECT_KEY}) + + +def noteskin_keys(name: str) -> list[str]: + """The catalog keys of the noteskin product of skin `name`: its asset and the flick views its units set up.""" + return sorted({skin_key(asset_name(name)), *(NOTE_PREFABS[v] for v in FLICK_SKIN_UNITS)}) + + +def _lane_settings() -> dict: + return { "laneCount": LANE_COUNT, "laneSize": list(LANE_VIEW["size"]), "laneTopRange": LANE_VIEW["topRange"], @@ -151,46 +185,64 @@ def settings(master: Path | None, options: LiveOptions = LiveOptions()) -> dict: "noteViewProgress": {"base": 1.065, "exponentScale": 45.0}, "tiltCenterLane": 11.5, } - if master is None: - return out - items = OPTION_ITEMS - if options.tables: - items = {**OPTION_ITEMS, **{k: OPTION_ITEM_TYPES[k] for k in PLAYER_ITEMS}} - opts = {} - for r in master_table(master, "MasterOptionDefault"): - if r["_presetId"] == 1 and r["_optionItemType"] in items: - opts[items[r["_optionItemType"]]] = r["_valueString"] - out["optionDefaults"] = opts - out["optionRanges"] = { - items.get(r["_optionItemType"], r["_optionItemType"]): [r["_minValue"], r["_maxValue"]] - for r in master_table(master, "MasterOptionRange") - if r["_optionItemType"] in items + + +def notes_record(tables: dict) -> dict: + """What notes_settings reads of the master tables `tables` {name: rows} (NOTES_TABLES): the preset-1 defaults and + the ranges of NOTE_ITEMS, the LIVE_SETTING_KEYS rows of MasterLiveSettings, the note skins and effect sets.""" + return { + "MasterOptionDefault": [ + r for r in tables["MasterOptionDefault"] if r["_presetId"] == 1 and r["_optionItemType"] in NOTE_ITEMS + ], + "MasterOptionRange": [r for r in tables["MasterOptionRange"] if r["_optionItemType"] in NOTE_ITEMS], + "MasterLiveSettings": [r for r in tables["MasterLiveSettings"] if r["_key"] in LIVE_SETTING_KEYS], + "MasterLiveNoteSkin": tables["MasterLiveNoteSkin"], + "MasterLiveNoteEffectSkin": tables["MasterLiveNoteEffectSkin"], } - ls = {r["_key"]: r["_value"] for r in master_table(master, "MasterLiveSettings")} - out["liveSettings"] = {k: ls[k] for k in LIVE_SETTING_KEYS if k in ls} - skins = {r["_id"]: r["_assetName"] for r in master_table(master, "MasterLiveNoteSkin")} - effects = {r["_id"]: r["_assetName"] for r in master_table(master, "MasterLiveNoteEffectSkin")} - out["skin"] = skins[int(opts["NoteDesignId"])] - out["effect"] = effects[int(opts["NoteEffectId"])] - out["judgementSprites"] = { - r["_noteSimulateJudgement"]: r["_spriteName"] for r in master_table(master, "MasterLiveJudgementSprite") + + +def default_assets(record: dict) -> tuple[str, str]: + """(note skin, note effect set) of the option defaults (NoteDesignId, NoteEffectId) of a notes_record.""" + opts = {NOTE_ITEMS[r["_optionItemType"]]: r["_valueString"] for r in record["MasterOptionDefault"]} + skins = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteSkin"]} + effects = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteEffectSkin"]} + return skins[int(opts["NoteDesignId"])], effects[int(opts["NoteEffectId"])] + + +def notes_settings(record: dict) -> dict: + """notes.json `settings` (module documentation) from a notes_record.""" + opts = {NOTE_ITEMS[r["_optionItemType"]]: r["_valueString"] for r in record["MasterOptionDefault"]} + ls = {r["_key"]: r["_value"] for r in record["MasterLiveSettings"]} + skins = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteSkin"]} + effects = {r["_id"]: r["_assetName"] for r in record["MasterLiveNoteEffectSkin"]} + skin, effect = default_assets(record) + return { + **_lane_settings(), + "optionDefaults": dict(sorted(opts.items())), + "optionRanges": { + NOTE_ITEMS[r["_optionItemType"]]: [r["_minValue"], r["_maxValue"]] + for r in sorted(record["MasterOptionRange"], key=lambda r: r["_optionItemType"]) + }, + "liveSettings": {k: ls[k] for k in LIVE_SETTING_KEYS if k in ls}, + "noteDisplayTimeMs": display_time_ms( + float(opts["NoteSpeed"]), + float(ls["note_speed_min"]), + float(ls["note_speed_max"]), + float(ls["note_speed_view_min"]), + float(ls["note_speed_view_max"]), + ), + "skin": skin, + "effect": effect, + "laneEffect": LANE_EFFECT, + "skins": {str(i): n for i, n in sorted(skins.items())}, + "effects": {str(i): n for i, n in sorted(effects.items())}, } - out["noteDisplayTimeMs"] = display_time_ms( - float(opts["NoteSpeed"]), - float(ls["note_speed_min"]), - float(ls["note_speed_max"]), - float(ls["note_speed_view_min"]), - float(ls["note_speed_view_max"]), - ) - if options.designs: - out["skins"] = {str(i): skins[i] for i in options.designs} - if options.effects or options.qualities: - out["effects"] = {str(i): effects[i] for i in options.values("NoteEffectId")} - return out def display_time_ms(speed: float, smin: float, smax: float, vmin: float, vmax: float) -> int: """NoteBeforePlayingTimeGetter.GetNoteDisplayOffsetTimeMs, in float32.""" + import numpy as np + f = np.float32 t = max(f(0), f(speed) - f(smin)) / (f(smax) - f(smin)) t = min(max(t, f(0)), f(1)) @@ -199,190 +251,103 @@ def display_time_ms(speed: float, smin: float, smax: float, vmin: float, vmax: f return int(f((f(vmin) + (f(vmax) - f(vmin)) * e) * f(1000))) -# UI subtrees of [EmbScene/Live] animated by exported clips (combo counter, judgement text); used only to name -# clip binding paths, the scene itself is exported by livescene. -SCENE_KEY = "EmbScene/Live" -SCENE_SUBTREES = ("UILiveCombo", "UILiveJudgement") -# The bar line view (MeasureLineDisplay): the scene's LiveAllNoteView -> _barLineView (LiveAllBarLineView) -> -# _container (LiveBarLineViewContainer) -> _elementPrefab (LiveBarLineView, the prefab the container instantiates) -NOTE_VIEW = ("LiveGameView/LiveGameCamera/screen_root/LiveGameAllNoteView", "LiveAllNoteView") -BAR_LINE_CHAIN = ( - ("_barLineView", "LiveAllBarLineView"), - ("_container", "LiveBarLineViewContainer"), - ("_elementPrefab", "LiveBarLineView"), -) -BAR_LINE_VIEW = "bar_line_view" +# ------------------------------------------------------------------ products +def _loop_clips(prefab: Prefab) -> dict[str, list[str]]: + """{Animator node path: clip ids} the LoopAnimationTimeSyncs of a prefab play: each one's `_loopAnim` on its + `_animator`.""" + out: dict[str, list[str]] = {} + for _, c in prefab.components("LoopAnimationTimeSync"): + clip = ref_id(c["fields"].get("_loopAnim")) + hit = prefab.component_of(c["fields"].get("_animator")) + if clip is not None and hit is not None: + out.setdefault(prefab.nodes[hit[0]]["path"], []).append(clip) + return out -TMP_FONT_NOTE = ( - "not exported: the note highway draws no TextMeshPro text with default options (the judge view's " - "fast / slow and assist texts stay hidden)" -) +def animator_bindings(objects: Objects, extra: Mapping[str, Mapping[str, Iterable[str]]] | None = None) -> dict: + """`bindings` of every prefab of the table: the clips of its Animators' controllers, of the LoopAnimationTimeSyncs + on them, and those `extra` {prefab GameObject id: {Animator node path: clip ids}} gives.""" + out = {} + for gid in prefab_ids(objects): + doc = objects.json(gid) + plays = controller_clips(objects, doc) + for path, clips in _loop_clips(Prefab(doc)).items(): + plays.setdefault(path, []).extend(clips) + for path, clips in ((extra or {}).get(gid) or {}).items(): + plays.setdefault(path, []).extend(clips) + table = clip_bindings(objects, doc, plays) + if table: + out[gid] = table + return out -def _drop_tmp_fonts(doc: dict, root: Path) -> list[str]: - """Reduce the font asset and material of every TextMeshProUGUI record to a name stub and delete the texture - files no record references any more (the TMP font atlases). Returns the deleted files.""" - - def walk(v): - if isinstance(v, dict): - if v.get("class") == "TextMeshProUGUI" and "m_fontAsset" in v: - fa, mat = v["m_fontAsset"], v.get("m_sharedMaterial") - if isinstance(fa, dict): - v["m_fontAsset"] = {"asset": fa.get("asset"), "name": fa.get("name"), "note": TMP_FONT_NOTE} - if isinstance(mat, dict): - v["m_sharedMaterial"] = { - "material": mat.get("material"), - "shader": mat.get("shader"), - "note": TMP_FONT_NOTE, - } - for x in v.values(): - walk(x) - elif isinstance(v, list): - for x in v: - walk(x) - - walk(doc) - used: set[str] = set() - - def refs(v): - if isinstance(v, dict): - t = v.get("texture") - if isinstance(t, str): - used.add(t) - for x in v.values(): - refs(x) - elif isinstance(v, list): - for x in v: - refs(x) - - refs(doc) - dropped = [] - for f in sorted((root / "textures").glob("*.png")): - rel = f"textures/{f.name}" - if rel not in used: - f.unlink() - dropped.append(rel) - return dropped +def _product(objects: Objects, doc: dict) -> tuple[dict, set[str], dict[str, set[str]]]: + shaders, roles = shader_table(objects) + doc["shaders"] = shaders + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + return doc, keep, roles -def skin_key(name: str) -> str: - """The note skin asset of MasterLiveNoteSkin._assetName `name` (LiveAddressablePath.GetLiveNoteSkinAssetPath).""" - return f"Live/Note/{name}/LiveNoteSkinAsset" - - -def effect_settings_key(effect: str) -> str: - """The note effect settings asset of effect set `effect` (LiveAddressablePath.GetLiveNoteEffectSkinAssetPath).""" - return f"Effect/Live/NoteEffect/{effect}/LiveNoteEffectAssetSettings" +def notes(objects: Objects, record: dict) -> tuple[dict, set[str], dict[str, set[str]]]: + """(notes.json, the object ids the player reads, {object id: the roles of its files it reads}) of the notes + product (module documentation; `record`: notes_record).""" + doc = { + "format": NOTES_FORMAT, + "settings": notes_settings(record), + "prefabs": {name: key_object(objects, key, "GameObject") for name, key in NOTE_PREFABS.items()}, + "laneEffect": key_object(objects, LANE_EFFECT_KEY, "LiveLaneEffectAssetSettings"), + "bindings": animator_bindings(objects), + } + return _product(objects, doc) + + +def noteskin(objects: Objects, name: str) -> tuple[dict, set[str], dict[str, set[str]]]: + """(noteskin.json, the object ids the player reads, roles of its files) of the note skin `name` (module + documentation).""" + skin = key_object(objects, skin_key(asset_name(name)), "LiveNoteSkinAsset") + fields = objects.json(skin) + out = {} + for view, unit_field in FLICK_SKIN_UNITS.items(): + unit = ref_id(fields.get(unit_field)) + clip = ref_id(objects.json(unit).get("_arrowLoopAnimation")) if unit and has_json(objects, unit) else None + if clip is None: + continue + gid = key_object(objects, NOTE_PREFABS[view], "GameObject") + doc = objects.json(gid) + prefab = Prefab(doc) + syncs = [c for c in doc["nodes"][0]["components"] if "_loopAnimationTimeSync" in c["fields"]] + if len(syncs) != 1: + raise ValueError(f"{view}: {len(syncs)} note views with a _loopAnimationTimeSync on its root") + sync = prefab.component_of(syncs[0]["fields"]["_loopAnimationTimeSync"]) + animator = prefab.component_of(sync[1]["fields"]["_animator"]) if sync is not None else None + if animator is None or component_class(animator[1]) != "Animator": + raise ValueError(f"{view}: its LoopAnimationTimeSync has no Animator") + table = clip_bindings(objects, doc, {prefab.nodes[animator[0]]["path"]: [clip]}) + if table: + out[gid] = table + return _product(objects, {"format": NOTES_FORMAT, "name": name, "skin": skin, "bindings": out}) + + +def noteeffect(objects: Objects, name: str) -> tuple[dict, set[str], dict[str, set[str]]]: + """(noteeffect.json, the object ids the player reads, roles of its files) of the note effect set `name` (module + documentation).""" + settings_id = key_object(objects, effect_settings_key(asset_name(name)), "LiveNoteEffectAssetSettings") + doc = {"format": NOTES_FORMAT, "name": name, "settings": settings_id, "bindings": animator_bindings(objects)} + return _product(objects, doc) -def effect_keys(cat: Catalog, effect: str) -> list[str]: - """The catalog keys of note effect set `effect` and of the lane effects (KEY_PREFIXES), in prefix and catalog - order.""" - keys: list[str] = [] - for p in KEY_PREFIXES: - keys += [k for k in cat.keys(p.format(effect=effect)) if k not in keys] - return keys - - -def _mono(ex: Exporter, o, cls: str): - if o is None or o.type.name != "MonoBehaviour" or ex.script_class(o) != cls: - raise RuntimeError(f"expected a {cls} MonoBehaviour, got {o and o.type.name}") - return o, o.read_typetree() - - -def bar_line_prefab(ex: Exporter) -> dict: - """The bar line view as a node list: the LiveBarLineView prefab the live scene's bar line container - instantiates (BAR_LINE_CHAIN from the scene's LiveAllNoteView), its root first.""" - env, graph = ex.load(SCENE_KEY) - path, cls = NOTE_VIEW - gos = set(graph.gos_at_path(path)) - comps = [c["component"] for go in gos for c in graph.go[go]["m_Component"]] - owners = [o for o in env.objects if o.type.name == "GameObject" and o.path_id in gos] - if len(gos) != 1 or len(owners) != 1: - raise RuntimeError(f"{SCENE_KEY}: {len(gos)} GameObjects at {path}") - hits = [ - c - for c in (ex.deref(owners[0], p) for p in comps) - if c is not None and c.type.name == "MonoBehaviour" and ex.script_class(c) == cls - ] - if len(hits) != 1: - raise RuntimeError(f"{SCENE_KEY}: {len(hits)} {cls} at {path}") - o, tt = _mono(ex, hits[0], cls) - for field, want in BAR_LINE_CHAIN: - o, tt = _mono(ex, ex.deref(o, tt[field]), want) - root = graph.tf_of_go.get(tt["m_GameObject"]["m_PathID"]) - if root is None or graph.tf[root]["m_Father"]["m_PathID"]: - raise RuntimeError(f"{SCENE_KEY}: the bar line view is not a prefab root of the scene's bundles") - return { - "key": SCENE_KEY, - "reference": ".".join(f for f, _ in BAR_LINE_CHAIN), - "nodes": ex.hierarchy(env, graph, root), - } +# Catalog key families exported whole (every key under the prefix): the note effect settings of the NoteEffectId +# option's set ({effect}) and the lane effect settings (LANE_EFFECT), with the effect prefabs they reference -def extract( - cat: Catalog, player: PlayerData, out_dir: Path, master: Path | None = None, options: LiveOptions = LiveOptions() -) -> dict: - """livenotes/ for the default options, plus the files of the variants `options` offers (the module - docstring). The variants' objects are exported after the default ones.""" - out_dir = Path(out_dir) - root = out_dir / "livenotes" - root.mkdir(parents=True, exist_ok=True) - ex = Exporter(cat, root, player=player, clip_format="livenotes", follow=("AnimatorController", "SpriteAtlas")) - doc: dict = {"settings": settings(master, options)} - skin = doc["settings"]["skin"] - - # note / line prefabs first: their hierarchies resolve clip binding paths of skin/effect assets - doc["prefabs"] = {name: ex.export_key(key) for name, key in PREFABS.items()} - doc["noteSkin"] = ex.export_key(skin_key(skin)) - keys = effect_keys(cat, doc["settings"]["effect"]) - keys += [k for k in SINGLE_KEYS if cat.has(k) and k not in keys] - assets, failures = {}, {} - - def export(ks): - for k in ks: - try: - assets[k] = ex.export_key(k) - except NotImplementedError as e: - failures[k] = str(e) - - export(keys) - other_skins = [n for n in doc["settings"].get("skins", {}).values() if n != skin] - if other_skins: - doc["noteSkins"] = {n: ex.export_key(skin_key(n)) for n in other_skins} - names = {int(i): n for i, n in doc["settings"].get("effects", {}).items()} - for effect, light in options.effect_sets(names): - if not cat.has(effect_settings_key(effect)): - if light: - continue # the game loads the set itself at quality Low (LiveOptions.effect_sets) - raise KeyError(f"effect set {effect}: {effect_settings_key(effect)} not in the catalog") - export([k for k in effect_keys(cat, effect) if k not in assets and k not in failures]) - if options.bar_lines: - doc["prefabs"][BAR_LINE_VIEW] = bar_line_prefab(ex) - doc["assets"] = assets - ex.add_path_source(SCENE_KEY, SCENE_SUBTREES) - unresolved = ex.resolve_pending() - doc["clips"] = ex.clips - doc["controllers"] = ex.controllers - doc["exportFailures"] = failures - - dropped = _drop_tmp_fonts(doc, root) - _, shader_summary = ex.dump_shaders(root / "shaders") - doc["shaders"] = {"index": "shaders/shaders.json", "names": shader_summary["names"]} - doc["convention"] = ( - "Unity space (left-handed, Y up); transforms are local TRS; quaternions (x,y,z,w); " - "colours as serialized (project colour space: see player.json)" - ) - write_json(root / "notes.json", doc) - return { - "prefabs": len(doc["prefabs"]), - "assets": len(assets), - "clips": len(ex.clips), - "controllers": len(ex.controllers), - "textures": len(ex.textures) - len(dropped), - "droppedFontTextures": dropped, - "failures": failures, - "unresolvedBindings": unresolved, - **shader_summary, - } +# UI subtrees of [EmbScene/Live] animated by exported clips (combo counter, judgement text); used only to name +# clip binding paths, the scene itself is exported by livescene. +# The bar line view (MeasureLineDisplay): the scene's LiveAllNoteView -> _barLineView (LiveAllBarLineView) -> +# _container (LiveBarLineViewContainer) -> _elementPrefab (LiveBarLineView, the prefab the container instantiates) +NOTE_VIEW = ("LiveGameView/LiveGameCamera/screen_root/LiveGameAllNoteView", "LiveAllNoteView") +BAR_LINE_CHAIN = ( + ("_barLineView", "LiveAllBarLineView"), + ("_container", "LiveBarLineViewContainer"), + ("_elementPrefab", "LiveBarLineView"), +) diff --git a/src/nnnotes/content/liveoptions.py b/src/nnnotes/content/liveoptions.py index d39a53d..7a63288 100644 --- a/src/nnnotes/content/liveoptions.py +++ b/src/nnnotes/content/liveoptions.py @@ -34,8 +34,6 @@ from dataclasses import dataclass, replace from pathlib import Path -from .score import master_table - # App.Options.OptionItemType (every value but None 0), the names of MasterOptionDefault / MasterOptionRange # `_optionItemType` OPTION_ITEM_TYPES = { @@ -312,6 +310,8 @@ def read_variants(self) -> list[dict]: def preset_defaults(master: Path) -> dict[int, str]: """OptionItemType -> MasterOptionDefault preset-1 value string (a fresh profile's options).""" + from .score import master_table + return { r["_optionItemType"]: r["_valueString"] for r in master_table(master, "MasterOptionDefault") @@ -326,6 +326,8 @@ def resolve(request: dict | None, master: Path | None) -> LiveOptions: return LiveOptions() if master is None: raise OptionSpecError("--live-option needs the master data") + from .score import master_table + master = Path(master) everything = bool(request.get(ALL)) preset = preset_defaults(master) diff --git a/src/nnnotes/content/livescene.py b/src/nnnotes/content/livescene.py index 1fc5a4e..29c79a9 100644 --- a/src/nnnotes/content/livescene.py +++ b/src/nnnotes/content/livescene.py @@ -1,45 +1,21 @@ -"""Live (rhythm game) stage/render data -> livescene/scene.json + textures + shaders. - -What exists on the live screen before the first note: - - player colour space, quality levels, URP pipelines, renderer data (player.py) - scene the addressable scene EmbScene/Live as a node list (Live prefab instance with - cameras, render canvas, background view and UI canvases; LiveGameView with - the lane camera, lane, note/effect containers; LiveStartTimelineDirector) - laneSkin lane skin sprites (lane_base, lane_tap_area, out_side_line) of the LiveSkinId option's - MasterLiveLaneSkin row - assets further prefabs by key: the start timeline of the band, the lane line prefab - master master rows the render path reads (music, quality settings, option defaults) - derived lane geometry computed with the game's formulas for the default options - (each entry names the function it mirrors) - postTextures film grain textures of the live renderers' post-process data - shaders every Shader in the loaded bundle closures, everything referenced, and the - shaders of the live camera renderers (GLES3 + other platforms, shader.py layout) - -Only what the LightWeight screen (mode 3) loads is exported (LiveResourceLoadPipelineFactory.Create): the mode-1 -stage (Band/{b}/live_stage/live_stage, its Volume Profile, StageBlinkLight), the background director and camera -blends are not loaded in mode 3; EmbLive/Volume/LiveStageVolume is referenced by nothing; the LiveGameVolume -profile is part of the scene (LiveEffectCamera's Volume). - -Objects are exported with export.Exporter in its "livescene" clip format (clips inlined once, -bindings with crc32 keys, path candidates and curve counts). - -Only the music row, the slice and the jacket sprite depend on the music: `band_part` exports the rest once for a -band and `music_part` completes it for each music of that band (the same document as `extract`). -""" +"""Live scene products assembled from shared prefab, shader and master records.""" from __future__ import annotations -import json -import math -from pathlib import Path - -import numpy as np - -from ..core.jsonio import write_json -from ..unity.export import Exporter +from .livenotes import BAR_LINE_CHAIN, NOTE_VIEW, animator_bindings from .liveoptions import OPTION_ITEM_TYPES -from .score import master_table +from .prefabs import ( + Objects, + Prefab, + builtin_root, + component_class, + component_ref, + has_json, + key_object, + mesh_roles, + ref_id, + shader_table, +) # --- keys ------------------------------------------------------------------- SCENE_KEY = "EmbScene/Live" @@ -70,7 +46,6 @@ # Scene nodes whose textures the LightWeight preview draws (Live/RenderCanvas images); # every other texture of the scene prefab belongs to UI the preview does not show (headers, gauges, pause, gekisou, # skill cut-in, finish direction, mode-1 background) and is not written: its record stays, with "exported": false. -SCENE_DRAWN_TEXTURE_NODES = ("Live/RenderCanvas/LightWeightBackgroundShadowImage",) # shaders the live screen finds by name (GraphicsSettings always-included, APK player data): # LightWeight composite and blur, MV/VJ video PLAYER_SHADERS = ( @@ -81,7 +56,6 @@ "UI/Default", ) # URP renderer indices used by the scene's live cameras -LIVE_RENDERERS = (2, 3, 4, 5) # option items the render path reads OPTION_ITEMS = { k: OPTION_ITEM_TYPES[k] @@ -89,451 +63,214 @@ } -def master_rows(master: Path, music_id: int) -> dict: - music = next(r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id) - opts = { - r["_optionItemType"]: r["_valueString"] - for r in master_table(master, "MasterOptionDefault") - if r["_presetId"] == 1 and r["_optionItemType"] in OPTION_ITEMS - } - return { - "liveMusic": music, - "liveQualitySettings": master_table(master, "MasterLiveQualitySettings"), - "liveLaneSkin": master_table(master, "MasterLiveLaneSkin"), - "liveStageVideo": master_table(master, "MasterLiveStageVideo"), - "optionDefaultsPreset1": {OPTION_ITEMS[k]: {"id": k, "value": v} for k, v in sorted(opts.items())}, - "optionRanges": { - OPTION_ITEMS[r["_optionItemType"]]: [r["_minValue"], r["_maxValue"]] - for r in master_table(master, "MasterOptionRange") - if r["_optionItemType"] in OPTION_ITEMS - }, - } +SCENE_FORMAT = 1 # scene.json "format" +SCENE_TABLES = ( + "MasterOptionDefault", + "MasterOptionRange", + "MasterLiveQualitySettings", + "MasterLiveLaneSkin", + "MasterBand", + "MasterLiveJudgementSprite", +) +LANE_LINE_KEY = ASSET_KEYS["laneLinePrefab"] +JUDGE_EFFECT_KEY = "EmbLive/Prefabs/UILiveNoteJudgeEffectView" +# the sprite assets of the live UI: {name: (key, script class)} +SPRITE_ASSETS = { + "combo": ("Live/UiSpriteAssets/LiveComboSpriteAsset", "LiveComboSpriteAsset"), + "judgement": ("Live/UiSpriteAssets/LiveJudgementSpriteAsset", "LiveJudgementSpriteAsset"), +} +TIMELINE_KEY = ASSET_KEYS["startTimeline"] +BACKGROUND_KEY = SPRITE_KEYS["lightweightBackground"] +# the built-in settings of `graphics`: {name: built-in selector} +GRAPHICS = {"graphics": "GraphicsSettings", "quality": "QualitySettings", "player": "PlayerSettings"} + + +def lane_skin_key(name: str, part: str) -> str: + """The sprite of part `part` (LANE_SKIN_PARTS) of the lane skin of MasterLiveLaneSkin._assetName `name`.""" + return f"Live/Lane/{name}/{part}" # --- derived lane geometry (mirrors the game functions named per entry) --- # LiveLaneLineView's constant tables: split border main / sub counts per LiveLaneSplitCountType # (None, Lane4, Lane6, Lane8, Lane12) -SPLIT_MAIN = (0, 4, 6, 8, 12) -SPLIT_SUB = (12, 12, 12, 0, 0) -LANE_COUNT = 24 # LiveDataCreator.CreateBootData passes 0x18 -# LiveBootDataCreator.CreateViewData: arguments of LiveSettingCreator.CreateLaneViewSettings -LANE_VIEW = { - "size": (1920.0, 1080.0), - "topRange": 0.05, - "bottomRange": 1.0, - "judgementScreenBottomPosition": 2.24, - "topPosition": 0.0, -} -REF_ASPECT = 1.7777778 # LiveGameView.FullInitialize -REF_FOV = 54.0 - -def vertical_fov(width: int, height: int) -> float: - """LiveGameView.CalcVerticalFovKeepingHorizontal, aspect clamped to <= 16:9 by the caller.""" - a = min(width / height, REF_ASPECT) - h = math.atan(math.tan(math.radians(REF_FOV) * 0.5) * REF_ASPECT) - return math.degrees(2 * math.atan(math.tan(h) / a)) - -def _node(nodes: list, path: str) -> dict: - hit = [n for n in nodes if n["path"] == path] - if len(hit) != 1: - raise KeyError(f"{path}: {len(hit)} nodes") - return hit[0] +# --- product ---------------------------------------------------------------- +def scene_record(tables: dict) -> dict: + """What scene_settings and scene_keys read of the master tables `tables` {name: rows} (SCENE_TABLES): the + preset-1 defaults and the ranges of OPTION_ITEMS, the quality settings, the lane skins, the bands and the + judgement sprites.""" + return { + "MasterOptionDefault": [ + r for r in tables["MasterOptionDefault"] if r["_presetId"] == 1 and r["_optionItemType"] in OPTION_ITEMS + ], + "MasterOptionRange": [r for r in tables["MasterOptionRange"] if r["_optionItemType"] in OPTION_ITEMS], + "MasterLiveQualitySettings": tables["MasterLiveQualitySettings"], + "MasterLiveLaneSkin": tables["MasterLiveLaneSkin"], + "MasterBand": tables["MasterBand"], + "MasterLiveJudgementSprite": tables["MasterLiveJudgementSprite"], + } -def _comp(node: dict, cls: str) -> dict: - hit = [c for c in node["components"] if c.get("class", c["type"]) == cls] - if len(hit) != 1: - raise KeyError(f"{node['path']}: {len(hit)} {cls}") - return hit[0] +def scene_keys(record: dict) -> list[str]: + """The catalog keys of the scene product: the scene, the lane line and judge effect prefabs, the UI sprite assets, + the sprites of every lane skin and the start timeline and background of every band.""" + keys = {SCENE_KEY, LANE_LINE_KEY, JUDGE_EFFECT_KEY, *(k for k, _ in SPRITE_ASSETS.values())} + keys.update(lane_skin_key(r["_assetName"], p) for r in record["MasterLiveLaneSkin"] for p in LANE_SKIN_PARTS) + keys.update(k.format(band=r["_id"]) for r in record["MasterBand"] for k in (TIMELINE_KEY, BACKGROUND_KEY)) + return sorted(keys) -def derive_lane(nodes: list, options: dict) -> dict: - """Lane geometry for the given option values (MasterOptionDefault preset 1 strings).""" - lv = _comp(_node(nodes, "LiveGameView/root/LiveGameLane"), "LiveLaneView") - llv = _comp(_node(nodes, "LiveGameView/root/LiveGameLane/lines"), "LiveLaneLineView") - jr = _node(nodes, "LiveGameView/root/LiveGameLane/judgement_root") - n = LANE_COUNT - width = float(np.float32(lv["_laneWidth"])) # double field, passed on as (float) - length = lv["_lineLength"] - unit = width / n - judge_z = jr["localPosition"]["z"] # parents are identity: world z of position 0 - split = int(options["GuidelineCount"]) - main, sub = SPLIT_MAIN[split], SPLIT_SUB[split] - settings = {s["LineType"]: s for s in llv["_settings"]} - lines = [] - for i in range(n + 1): # LiveLaneLineView.UpdateProperties - if i == 0 or i == n: - t = 1 # GetSettings: OutSide - elif main and i % (n // main) == 0: - t = 0 # Normal - elif sub and i % (n // sub) == 0: - t = 2 # Space - else: - lines.append({"index": i, "active": False}) - continue - st = settings[t] - x = -width * 0.5 + i * unit - if t == 2: - ln, z0 = llv["_spaceLineLength"], judge_z - llv["_spaceLineLength"] * 0.5 - else: - ln, z0 = length, 0.0 - to_w = st["LineWidthTo"] * 2 if (i == n // 2 and t != 2) else st["LineWidthTo"] - # LiveLaneLine.SetLine: _anchor 2 (left side line) x -= startWidth/2, _anchor 1 (right) x += startWidth/2 - if i == 0: - x -= st["LineWidthFrom"] * 0.5 - elif i == n: - x += st["LineWidthFrom"] * 0.5 - lines.append( - { - "index": i, - "active": True, - "type": ("Normal", "OutSide", "Space")[t], - "start": [x, 0.0, z0], - "end": [x, 0.0, z0 + ln], - "startWidth": st["LineWidthFrom"], - "endWidth": to_w, - "gradient": st["LineColor"], - "alphaControlled": t != 1, - } - ) +def scene_settings(record: dict) -> dict: + """scene.json `settings` (module documentation) from a scene_record.""" + opts = {OPTION_ITEMS[r["_optionItemType"]]: r["_valueString"] for r in record["MasterOptionDefault"]} + skins = {r["_id"]: r["_assetName"] for r in record["MasterLiveLaneSkin"]} + judgements = sorted(record["MasterLiveJudgementSprite"], key=lambda r: r["_noteSimulateJudgement"]) return { - "laneCount": n, - "laneWidth": width, - "unitWidth": unit, - "lineLength": length, - "judgementZ": judge_z, - "unitCentersX": [unit * (i + 0.5) - width * 0.5 for i in range(n)], - "spawnRoots": [[unit * (i + 0.5) - width * 0.5, 0.0, length] for i in range(n)], - "judgementPositions": [[unit * (i + 0.5) - width * 0.5, 0.0, judge_z] for i in range(n)], - "splitCountType": split, - "splitBorderMainCount": main, - "splitBorderSubCount": sub, - "lines": lines, - "lineAlpha": min(max(int(options["GuidelineOpacity"]) / 100.0, 0.0), 1.0), - "laneBaseAlpha": min(max(int(options["LaneOpacity"]) / 100.0, 0.0), 1.0), - "showJudgementLine": options["JudgePositionDisplay"].upper() == "TRUE", - "laneJudgementPosOffset": (int(options["JudgePosition"]) + 5) / -10.0 + 1.0, - "screenPlane": lane_2d(), - "sources": { - "lines": "LiveLaneLineView.UpdateProperties, GetSettings, LiveLaneLine.SetLine", - "spawnRoots": "LiveNoteSpawnRoot.UpdateProperties", - "judgementPositions": "LiveNoteJudgementRoot3D.UpdateProperties", - "options": "LiveBootDataCreator.CreateViewData", - "screenPlane": "LiveSettingCreator.CreateLaneViewSettings, JudgementPositionLogic.GetJudgementPositions, " - "LiveViewUtility.GetNoteSpawnPosition", + "optionDefaults": dict(sorted(opts.items())), + "optionRanges": { + OPTION_ITEMS[r["_optionItemType"]]: [r["_minValue"], r["_maxValue"]] + for r in sorted(record["MasterOptionRange"], key=lambda r: r["_optionItemType"]) }, + "qualitySettings": record["MasterLiveQualitySettings"], + "laneSkin": skins[int(opts["LiveSkinId"])], + "laneSkins": {str(i): n for i, n in sorted(skins.items())}, + "judgementSprites": {str(r["_noteSimulateJudgement"]): r["_spriteName"] for r in judgements}, } -def lane_2d(lane_count: int = LANE_COUNT) -> dict: - """LiveSettingCreator.CreateLaneViewSettings as CreateViewData calls it (LANE_VIEW: size 1920x1080, - topRange 0.05, bottomRange 1, judgementScreenBottomPosition 2.24, topPosition 0) and - JudgementPositionLogic.GetJudgementPositions; units of 100 reference pixels.""" - w, h = LANE_VIEW["size"][0] / 100.0, LANE_VIEW["size"][1] / 100.0 - top, bottom = LANE_VIEW["topRange"], LANE_VIEW["bottomRange"] - lt, rt = [-w * top * 0.5, h * 0.5, 0.0], [w * top * 0.5, h * 0.5, 0.0] - lb, rb = [-w * bottom * 0.5, -h * 0.5, 0.0], [w * bottom * 0.5, -h * 0.5, 0.0] - t = min(max(LANE_VIEW["judgementScreenBottomPosition"] / (lt[1] - lb[1]), 0.0), 1.0) - jp = [] - for i in range(lane_count): - f = min(max(i / lane_count, 0.0), 1.0) - xb = (rb[0] - lb[0]) / lane_count * 0.5 + lb[0] + (rb[0] - lb[0]) * f - xt = (rt[0] - lt[0]) / lane_count * 0.5 + lt[0] + (rt[0] - lt[0]) * f - yb, yt = lb[1] + (rb[1] - lb[1]) * f, lt[1] + (rt[1] - lt[1]) * f - jp.append([xb + t * (xt - xb), yb + (yt - yb) * t, 0.0]) - # LiveViewUtility.GetNoteSpawnPosition: intersection of the two side edges - s1 = (lb[1] - lt[1]) / (lb[0] - lt[0]) - s2 = (rb[1] - rt[1]) / (rb[0] - rt[0]) - c1 = lt[1] - lt[0] * s1 - sx = ((rt[1] - rt[0] * s2) - c1) / (s1 - s2) - return { - "vertices": [lt, rt, lb, rb], - "judgementPositions": jp, - "spawnPosition": [sx, c1 + s1 * sx], - "pixelsPerUnit": 100.0, - } +def _one_component(objects: Objects, prefabs: list[str], cls: str) -> tuple[str, dict]: + """(prefab GameObject id, component) of the one component of class `cls` of the hierarchies `prefabs`.""" + hits = [(g, c) for g in prefabs for _, c in Prefab(objects.json(g)).components(cls)] + if len(hits) != 1: + raise ValueError(f"{len(hits)} {cls} components") + return hits[0] -# --- extraction ------------------------------------------------------------- -def _component(nodes: list, cls: str) -> dict: - hit = [c for n in nodes for c in n["components"] if c.get("class", c["type"]) == cls] - if len(hit) != 1: - raise KeyError(f"{len(hit)} {cls} components") - return hit[0] +def _tracks(objects: Objects, asset: str) -> list[tuple[str, dict]]: + """(id, document) of every track of a TimelineAsset, depth first through group tracks.""" + out = [] + def walk(refs) -> None: + for r in refs: + t = ref_id(r) + if t is not None: + out.append((t, objects.json(t))) + walk(out[-1][1].get("m_Children", ())) -def bind_timeline(ex: Exporter, doc: dict, key: str) -> dict: - """Bind the clips of the start timeline's tracks to the Animators the game binds them to - (TIMELINE_TRACK_ANIMATORS): binding paths resolve against that GameObject's subtree, and each track records - `boundAnimator` {key, gameObject, field}. Returns binding counts per track.""" - tl = doc["assets"]["startTimeline"] - env, tl_graph = ex.load(key) - scene_graph = ex.load(SCENE_KEY)[1] - dir_json = _component(tl["nodes"], "PlayableDirector") - dir_path = next(n["path"] for n in tl["nodes"] if any(c is dir_json for c in n["components"])) - directors = [ - o - for o in env.objects - if o.type.name == "PlayableDirector" - and tl_graph.path(tl_graph.tf_of_go[o.read_typetree()["m_GameObject"]["m_PathID"]]) == dir_path + walk(objects.json(asset).get("m_Tracks", ())) + return out + + +def _track_clips(objects: Objects, track: dict) -> list[str]: + """The clips a track plays: the clips of its TimelineClips' AnimationPlayableAssets and its infinite clip.""" + out = [] + for c in track.get("m_Clips", ()): + a = ref_id(c.get("m_Asset")) + clip = ref_id(objects.json(a).get("m_Clip")) if a is not None else None + if clip is not None: + out.append(clip) + infinite = ref_id(track.get("m_AnimClip")) + return out + ([infinite] if infinite is not None else []) + + +def _band(objects: Objects, band: int, scene: list[str], plays: dict) -> dict: + """The `bands` entry of band `band` (module documentation); adds what each track plays to `plays` {prefab + GameObject id: {Animator node path: clip ids}}.""" + key = TIMELINE_KEY.format(band=band) + gid = key_object(objects, key, "GameObject") + assets = [ + ref_id(c["fields"].get("m_PlayableAsset")) for _, c in Prefab(objects.json(gid)).components("PlayableDirector") ] - if len(directors) != 1: - raise RuntimeError(f"{key}: {len(directors)} PlayableDirectors at {dir_path}") - asset_o = ex.deref(directors[0], directors[0].read_typetree()["m_PlayableAsset"]) - tracks_json = {t["m_Name"]: t for t in dir_json["m_PlayableAsset"]["m_Tracks"]} - counts = {} - for _track_o, tt, clips in ex.timeline_tracks(asset_o): - name = tt["m_Name"] + if len(assets) != 1 or assets[0] is None: + raise ValueError(f"{key}: {len(assets)} PlayableDirectors, expected one playing a TimelineAsset") + owners = {"scene": scene, "timeline": [gid]} + tracks = {} + for tid, track in _tracks(objects, assets[0]): + name, clips = track.get("m_Name"), _track_clips(objects, track) if name not in TIMELINE_TRACK_ANIMATORS: if clips: - raise RuntimeError(f"{key}: track {name} with clips has no known binding") + raise ValueError(f"{key}: track {name} plays clips on no known Animator") continue where, cls, field = TIMELINE_TRACK_ANIMATORS[name] - nodes, graph, owner = ( - (doc["scene"]["nodes"], scene_graph, SCENE_KEY) if where == "scene" else (tl["nodes"], tl_graph, key) - ) - ref = _component(nodes, cls)[field] - if not ref or ref.get("component") != "Animator": - raise RuntimeError(f"{cls}.{field}: {ref}") - root = ref["gameObject"] - tracks_json[name]["boundAnimator"] = {"key": owner, "gameObject": root, "field": f"{cls}.{field}"} - counts[name] = [ex.bind_clip(c, graph, root) for c in clips] - return counts - - -def lane_skin(rows: dict) -> str: - """Lane skin asset name: option LiveSkinId (305) -> MasterLiveLaneSkin row of that id (LaneSkinLoadStep).""" - sid = int(rows["optionDefaultsPreset1"]["LiveSkinId"]["value"]) - hit = [r for r in rows["liveLaneSkin"] if r["_id"] == sid] - if len(hit) != 1: - raise KeyError(f"MasterLiveLaneSkin: {len(hit)} rows with id {sid} (option LiveSkinId)") - return hit[0]["_assetName"] - - -def _texture_records(v, out: list) -> None: - if isinstance(v, dict): - if isinstance(v.get("texture"), str) and v["texture"].startswith("textures/"): - out.append(v) - for x in v.values(): - _texture_records(x, out) - elif isinstance(v, list): - for x in v: - _texture_records(x, out) - - -def _drop_scene_textures(doc: dict, base: Path, unlink: bool = True) -> list[str]: - """The texture files only the undrawn part of the scene prefab references (SCENE_DRAWN_TEXTURE_NODES): their - records marked "exported": false, the files deleted (`unlink`; else the caller does not write them). Returns - the files.""" - keep: list = [] - for k, v in doc.items(): - if k != "scene": - _texture_records(v, keep) - for n in doc["scene"]["nodes"]: - if n["path"] in SCENE_DRAWN_TEXTURE_NODES: - _texture_records(n["components"], keep) - kept = {r["texture"] for r in keep} - scene: list = [] - _texture_records(doc["scene"], scene) - dropped = sorted({r["texture"] for r in scene} - kept) - for r in scene: - if r["texture"] in dropped: - r["exported"] = False - if unlink: - for rel in dropped: - (base / rel).unlink() - return dropped - - -class SharedSceneMismatch(RuntimeError): - """A music's scene cannot be composed from its band's part (see music_part); export it whole.""" - - -def _export( - cat, - player, - base: Path, - rows: dict | None, - band: int, - skin: str, - extra_keys: dict | None, - slice_: dict | None, - jacket: str | None, -): - """The export sequence of extract (jacket None: without the jacket sprite, the slice and the music rows left as - placeholders for music_part). -> (exporter, doc, shader index, shader summary, timeline counts, dropped).""" - ex = Exporter(cat, base, player=player, clip_format="livescene", defer_writes=True) - graphics = player.graphics() - doc: dict = {"player": graphics} - doc["slice"] = slice_ - doc["scene"] = ex.prefab(SCENE_KEY) - doc["laneSkin"] = {part: ex.key_sprite(f"Live/Lane/{skin}/{part}") for part in LANE_SKIN_PARTS} - keys = dict(ASSET_KEYS) - keys.update(extra_keys or {}) - doc["assets"] = {name: ex.export_key(key.format(band=band)) for name, key in keys.items()} - timeline = bind_timeline(ex, doc, keys["startTimeline"].format(band=band)) - doc["sprites"] = { - name: ex.key_sprite(key.format(band=band, jacket=jacket)) - for name, key in SPRITE_KEYS.items() - if jacket is not None or name != "jacket" - } - if rows: - doc["master"] = rows if jacket is not None else None - opts = {k: v["value"] for k, v in rows["optionDefaultsPreset1"].items()} - doc["derived"] = { - "lane": derive_lane(doc["scene"]["nodes"], opts), - "fovRule": { - "refFov": REF_FOV, - "refAspect": REF_ASPECT, - "source": "LiveGameView.FullInitialize / CalcVerticalFovKeepingHorizontal", - "examples": { - f"{w}x{h}": vertical_fov(w, h) for w, h in ((2340, 1080), (1920, 1080), (1440, 1080), (2048, 1536)) - }, - }, - } - for name in PLAYER_SHADERS: - ex.add_shader(player.shader(name)) - rl = graphics["pipelines"][graphics["defaultPipeline"]]["m_RendererDataList"] - renderers = [rl[i] for i in LIVE_RENDERERS] - for rn in renderers: - for o in player.renderer_shaders(rn): - ex.add_shader(o) - doc["postTextures"] = {} - for rn in renderers: - if graphics["renderers"][rn].get("postProcessData"): - doc["postTextures"][rn] = { - "filmGrainTex": [ex.texture(o) for o in player.post_textures(rn, "filmGrainTex")] - } - index, summary = ex.dump_shaders(base / "shaders") - doc["shaders"] = {"index": "shaders/shaders.json", "names": summary["names"], "cameraRenderers": renderers} - doc["convention"] = ( - "Unity space (left-handed, Y up); transforms are local TRS; " - "quaternions (x,y,z,w); colours in the project's Gamma space" - ) - ex.resolve_pending() # bindings whose target hierarchy / component class was exported after the clip - dropped = _drop_scene_textures(doc, base, unlink=False) - ex.write_textures(skip=dropped) - return ex, doc, index, summary, timeline, dropped - - -def _summary(doc: dict, index: list, summary: dict, textures: int, dropped: list, timeline) -> dict: - gles3 = sum(1 for r in index for v in r["variants"] if v["platform"] == "gles3") + owner, comp = _one_component(objects, owners[where], cls) + hit = component_ref(objects, owner, comp["fields"].get(field)) + if hit is None or component_class(hit[2]) != "Animator": + raise ValueError(f"{key}: {cls}.{field} of track {name} is no Animator") + path = objects.json(hit[0])["nodes"][hit[1]]["path"] + tracks[tid] = {"name": name, "prefab": hit[0], "animator": path} + plays.setdefault(hit[0], {}).setdefault(path, []).extend(clips) return { - "scene": "livescene/scene.json", - "nodes": len(doc["scene"]["nodes"]), - "assets": list(doc["assets"]), - "textures": textures - len(dropped), - "droppedTextures": len(dropped), - "shaders": summary["count"], - "variants": summary["variants"], - "gles3Variants": gles3, - "timelineBindings": timeline, + "timeline": gid, + "background": key_object(objects, BACKGROUND_KEY.format(band=band), "Sprite"), + "tracks": tracks, } -def extract( - cat, - player, - out_dir: Path, - master: Path | None = None, - music_id: int = 100001, - band: int | None = None, - band_choice: dict | None = None, - extra_keys: dict | None = None, -) -> dict: - """Write /livescene/scene.json (+ textures/, shaders/) and return a summary. - - `band`: the band of the LightWeight background (LightWeightBackgroundLoadStep.d__3: - SelfMemberList[2].BandID) and of the start timeline (LiveResourceBandResolver.Resolve). The game - takes it from the player's deck; the caller chooses it (live.resolve_band) and `band_choice` records how. - `master` (decoded master JSON dir) adds the music/option rows and the derived lane geometry; with `master` - the band is required. Without `master`: band 1 unless given, lane skin skin001.""" - out_dir = Path(out_dir) - rows = master_rows(master, music_id) if master else None - if band is None: - if rows: - raise ValueError( - f"music {music_id}: no band given; the LightWeight background and the start timeline " - "take the band of the deck centre (SelfMemberList[2]), see live.resolve_band" - ) - band = 1 - skin = lane_skin(rows) if rows else "skin001" - base = out_dir / "livescene" - base.mkdir(parents=True, exist_ok=True) - slice_ = {"musicId": music_id, "band": band, "laneSkin": skin} - if band_choice: - slice_["bandChoice"] = band_choice - jacket = rows["liveMusic"]["_jacketAssetName"] if rows else "jkt_001_100001" - ex, doc, index, summary, timeline, dropped = _export( - cat, player, base, rows, band, skin, extra_keys, slice_, jacket - ) - write_json(base / "scene.json", doc) - return _summary(doc, index, summary, len(ex.textures), dropped, timeline) - - -BAND_DOC = "band.json" - - -def band_part( - cat, player, out_dir: Path, master: Path, music_id: int, band: int, extra_keys: dict | None = None -) -> dict: - """What `extract` writes for any music of `band`: /livescene/ (textures, shaders; no scene.json) and - /band.json (the document with the slice, the jacket sprite and the music rows left out). `music_id`: - any music of the band (the option rows it reads are the same for every music).""" - out_dir = Path(out_dir) - rows = master_rows(master, music_id) - base = out_dir / "livescene" - base.mkdir(parents=True, exist_ok=True) - ex, doc, index, summary, timeline, dropped = _export( - cat, player, base, rows, band, lane_skin(rows), extra_keys, None, None - ) - part = { - "band": band, - "doc": doc, - "summary": _summary(doc, index, summary, len(ex.textures), dropped, timeline), - "textures": sorted({r["texture"] for r in _records(doc["scene"])}), +def _bar_line_view(objects: Objects, scene: list[str]) -> str: + """The prefab GameObject of the bar line view: BAR_LINE_CHAIN from the scene's LiveAllNoteView.""" + path, cls = NOTE_VIEW + owners = [g for g in scene if any(n["path"] == path for n in objects.json(g)["nodes"])] + if len(owners) != 1: + raise ValueError(f"{SCENE_KEY}: {len(owners)} hierarchies with a node at {path}") + gid, comp = _one_component(objects, owners, cls) + node = 0 + for field, want in BAR_LINE_CHAIN: + hit = component_ref(objects, gid, comp["fields"].get(field)) + if hit is None or component_class(hit[2]) != want: + raise ValueError(f"{SCENE_KEY}: {cls}.{field} is no {want}") + (gid, node, comp), cls = hit, want + if node != 0: + raise ValueError(f"{SCENE_KEY}: the bar line view is not the root of its prefab") + return gid + + +def scene(objects: Objects, record: dict) -> tuple[dict, set[str], dict[str, set[str]]]: + """(scene.json, the object ids the player reads, {object id: the roles of its files it reads}) of the scene + product (module documentation; `record`: scene_record).""" + roots = [o for o in objects.key(SCENE_KEY) if o in objects] + gos = [o for o in roots if objects.info(o).get("class") == "GameObject" and has_json(objects, o)] + render = [o for o in roots if objects.info(o).get("class") == "RenderSettings"] + if len(render) != 1: + raise ValueError(f"{SCENE_KEY}: {len(render)} RenderSettings") + plays: dict[str, dict[str, list[str]]] = {} + bands = { + str(r["_id"]): _band(objects, r["_id"], gos, plays) + for r in sorted(record["MasterBand"], key=lambda r: r["_id"]) + if objects.key(TIMELINE_KEY.format(band=r["_id"])) } - write_json(out_dir / BAND_DOC, part) - return part - - -def _records(v) -> list: - out: list = [] - _texture_records(v, out) - return out - + graphics = {name: builtin_root(objects, cls) for name, cls in GRAPHICS.items()} + always = (ref_id(r) for r in objects.json(graphics["graphics"]).get("m_AlwaysIncludedShaders", ())) + shaders, roles = shader_table(objects, found=[o for o in always if o is not None]) + missing = [n for n in PLAYER_SHADERS if n not in shaders] + if missing: + raise ValueError(f"{SCENE_KEY}: a lookup by name finds no shader {', '.join(missing)}") + skins = sorted(record["MasterLiveLaneSkin"], key=lambda r: r["_id"]) + doc = { + "format": SCENE_FORMAT, + "scene": gos, + "renderSettings": render[0], + "graphics": graphics, + "laneSkins": { + r["_assetName"]: { + p: key_object(objects, lane_skin_key(r["_assetName"], p), "Sprite") for p in LANE_SKIN_PARTS + } + for r in skins + }, + "prefabs": { + "laneLine": key_object(objects, LANE_LINE_KEY, "GameObject"), + "judgeEffectView": key_object(objects, JUDGE_EFFECT_KEY, "GameObject"), + "barLineView": _bar_line_view(objects, gos), + }, + "spriteAssets": {name: key_object(objects, key, cls) for name, (key, cls) in SPRITE_ASSETS.items()}, + "bands": bands, + "settings": scene_settings(record), + "bindings": animator_bindings(objects, plays), + "shaders": shaders, + } + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + return doc, keep, roles -def music_part( - cat, player, out_dir: Path, part: dict, master: Path, music_id: int, band_choice: dict | None = None -) -> dict: - """/livescene/scene.json of one music from its band's part (band_part; the part's livescene/ files - already in /livescene): the document extract writes. Raises SharedSceneMismatch when the jacket's - bundle closure holds shaders or a hierarchy (extract would dump or resolve against them) or the jacket texture - is one of the scene's (extract would keep it).""" - from ..unity.export import Exporter as _Exporter - out_dir = Path(out_dir) - band = part["band"] - rows = master_rows(master, music_id) - doc = json.loads(json.dumps(part["doc"])) - key = SPRITE_KEYS["jacket"].format(band=band, jacket=rows["liveMusic"]["_jacketAssetName"]) - ex = _Exporter(cat, out_dir / "livescene", player=player, clip_format="livescene") - env, _ = ex.load(key) - kinds = {o.type.name for o in env.objects} & {"Shader", "GameObject", "Transform", "RectTransform"} - if kinds: - raise SharedSceneMismatch(f"{key}: its closure holds {', '.join(sorted(kinds))}") - jacket = ex.key_sprite(key) - if {r["texture"] for r in _records(jacket)} & set(part["textures"]): - raise SharedSceneMismatch(f"{key}: its texture is one of the scene's") - slice_ = {"musicId": music_id, "band": band, "laneSkin": lane_skin(rows)} - if band_choice: - slice_["bandChoice"] = band_choice - doc["slice"] = slice_ - doc["sprites"]["jacket"] = jacket - doc["master"] = rows - write_json(out_dir / "livescene" / "scene.json", doc) - s = dict(part["summary"]) - s["textures"] += len(ex.textures) - return s +# --- extraction ------------------------------------------------------------- diff --git a/src/nnnotes/content/liveui.py b/src/nnnotes/content/liveui.py index 17e2739..a2732d6 100644 --- a/src/nnnotes/content/liveui.py +++ b/src/nnnotes/content/liveui.py @@ -1,40 +1,8 @@ -"""Live start canvas (Live/UILiveStartCanvas, LightWeight mode) -> /liveui/ (liveui.json, textures/). - -livescene/scene.json already carries the canvas hierarchy with every component (serialized TMP texts, inline -sprite records, the start timeline and its UILiveStartCanvasTrack clips) and livescene/shaders the shaders it -draws with. This module adds what a renderer needs beyond that to draw the canvas the way -UILiveStartCanvas.Initialize leaves it: - - strings the texts Initialize sets: music title, singer, the credit lines (LocalizeText master text id + - replacement word), music level, high score, resolved as the game resolves them - texts per TMP text of the drawn part: the LocalizeText font / material / line spacing overrides; with - fonts "open" also its text record (textstyle: layout, colours, style values of its material) - fonts fonts "game": TMP font assets reduced to the characters shown (tmpfont); FZLTH's runtime glyphs - generated from its source font; VibeMOPro's glyph pair adjustment lookup (kerning). fonts "open" - (default): the font asset names only, no font data and no atlases - materials fonts "game": the localized TMP materials (" - Default") and the fonts' default materials; - fonts "open": none - sprites WhiteRect, Songtitle_base, the Difficulty_* icons of _difficultyPairs and the music jacket; their - texel blocks copied into textures/ (export.TexelPacker, deferred-texture mode) - controller the root Animator's controller (evidence only: the start timeline drives the canvas) - -Texts and fonts follow the client language `language` (a catalog language code, languages.LANGUAGES): its -LanguageMode selects the text table column, the LocalizeManager fonts and the line spacing. -""" +"""Live start-canvas paths and localized strings from explicit master records.""" from __future__ import annotations -import json -from pathlib import Path - -from ..core.jsonio import write_json from ..source import languages -from ..source.catalog import Catalog -from ..unity import fontsettings, textstyle, tmpfont -from ..unity.export import Exporter, TexelPacker, _safe -from ..unity.player import PlayerData -from ..unity.textstyle import LANGUAGE_LINE_SPACING -from .score import master_table SCENE_KEY = "EmbScene/Live" CANVAS = "Live/UILiveStartCanvas" @@ -42,32 +10,19 @@ # Parts drawn in LightWeight mode: root/base (a _simpleModeOnlyObjects entry), center_bottom and # simple_music_jacket_panel (left_top is _normalModeOnlyObjects, right_top/battle_panel is _battleLiveGameObjects). DRAWN = (f"{CANVAS}/root/base", f"{CONTENT}/center_bottom", f"{CONTENT}/simple_music_jacket_panel") -JACKET_KEY = "Image/Jacket/{jacket}" # App.LiveBase.LiveDifficulty LIVE_DIFFICULTY = {"easy": 0, "normal": 1, "hard": 2, "expert": 3, "master": 4} -TMP_STUBS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet") -TMP_CLASS = "TextMeshProUGUI" JAPANESE_COLUMN = languages.column("ja") -FONT_CHOICES = ("open", "game") -FONT_NOTE = 'not exported (fonts "open"): the text record in `texts` describes the text without font data' def _drawn(path: str) -> bool: return any(path == d or path.startswith(d + "/") for d in DRAWN) -def _comp(node: dict, cls: str) -> dict | None: - hit = [c for c in node["components"] if c.get("class", c["type"]) == cls] - if len(hit) > 1: - raise RuntimeError(f"{node['path']}: {len(hit)} {cls}") - return hit[0] if hit else None - - -def resolve_strings(master: Path, music_id: int, difficulty: str, column: str) -> tuple[dict, dict]: - """The strings UILiveStartCanvas.Initialize sets (IReadOnlyLiveMusicScore slots 2..8), for a fresh profile, with - the text table column `column` of the current LanguageMode. -> (strings, sources).""" - music = next(r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id) - text = {r["_id"]: r for r in master_table(master, "MasterText")} +def record_strings(tables: dict, music_id: int, difficulty: str, column: str) -> tuple[dict, dict]: + """The live start strings and their sources from explicit master-table rows.""" + music = next(r for r in tables["MasterLiveMusic"] if r["_id"] == music_id) + text = {r["_id"]: r for r in tables["MasterText"]} def localized(tid: str) -> str: # MasterLoader.GetLocalizedText in the current LanguageMode: the column as it is, "" included (no @@ -93,10 +48,10 @@ def credit(label_id: str, word_id: str) -> str: # GetBandNameTextId: _bandNameTextID, else MasterBand(_bandIDs[0])._nameTextID band_tid = music["_bandNameTextID"] if not band_tid: - band = next(r for r in master_table(master, "MasterBand") if r["_id"] == music["_bandIDs"][0]) + band = next(r for r in tables["MasterBand"] if r["_id"] == music["_bandIDs"][0]) band_tid = band["_nameTextID"] score_id = music[f"_{difficulty}ID"] - score = next(r for r in master_table(master, "MasterLiveMusicScore") if r["_id"] == score_id) + score = next(r for r in tables["MasterLiveMusicScore"] if r["_id"] == score_id) strings = { "musicTitle": title, "singerName": localized(band_tid), @@ -140,262 +95,3 @@ def credit(label_id: str, word_id: str) -> str: "_musicLevelTexts": "musicLevel", "_highScoreTexts": "highScore", } - - -def _game_fonts(ex: Exporter, fontset, texts: dict, chars_by_font: dict, packer: TexelPacker): - """The game's TMP fonts of the texts, reduced to the characters shown (tmpfont), their atlas blocks added to - `packer`. -> (glyph coverage, materials, fonts).""" - coverage: dict[str, dict] = {} - needed: dict[str, set] = {} - runtime_units: dict[str, set] = {} - primaries = set() - for fname in sorted(chars_by_font): - primary = fontset.by_key(fontsettings.font_dir(fname) + fname) - primaries.add(primary.name) - cov, nd, rt = tmpfont.glyph_coverage(fontset, primary, sorted(chars_by_font[fname])) - if cov["missing"]: - raise RuntimeError(f"{fname}: characters {cov['missing']} missing from the font chain") - coverage[fname] = cov - for k, v in nd.items(): - needed.setdefault(k, set()).update(v) - for k, v in rt.items(): - runtime_units.setdefault(k, set()).update(v) - materials, text_mats = tmpfont.text_materials( - ex, fontset, [t["localized"] for t in texts.values()], primaries, needed, runtime_units - ) - font_out = tmpfont.export_fonts( - ex, - fontset, - primaries, - needed, - runtime_units, - text_mats, - packer, - "characters shown by the live start canvas (plus baked control characters); " - "runtime characters generated from the source font (runtimeGlyphs)", - ) - return coverage, materials, font_out - - -def extract( - cat: Catalog, - player: PlayerData, - out_dir: Path, - master: Path, - music_id: int, - difficulty: str, - *, - language: str, - fonts: str = "open", -) -> dict: - """Write /liveui/ (liveui.json, textures/) for one live and return a summary. Needs - /livescene/shaders/shaders.json (livescene.extract) for the shader check. `language`: the client - language (a languages.LANGUAGES code); `fonts`: "open" (text records, font names only) or "game" (the game's - TMP fonts reduced to the characters shown; needs the `fonts` extra).""" - out_dir = Path(out_dir) - base = out_dir / "liveui" - base.mkdir(parents=True, exist_ok=True) - if difficulty not in LIVE_DIFFICULTY: - raise KeyError(f"difficulty {difficulty}") - if fonts not in FONT_CHOICES: - raise ValueError(f"fonts {fonts}: one of {', '.join(FONT_CHOICES)}") - mode, column = languages.mode(language), languages.column(language) - ex = Exporter( - cat, - base, - player=player, - textures="deferred", - stub_assets=TMP_STUBS, - follow=("AnimatorController",), - font_stubs=fontsettings.builtin() if fonts == "open" else None, - ) - packer = TexelPacker(base) - - # -- the canvas subtree of the scene (deferred: sprite / texture references) ------------------------- - env, graph = ex.load(SCENE_KEY) - roots = [p for p in graph.tf if graph.path(p) == CANVAS] - if len(roots) != 1: - raise RuntimeError(f"{CANVAS}: {len(roots)} transforms") - nodes = ex.hierarchy(env, graph, roots[0]) - by_path: dict[str, list] = {} - for n in nodes: - by_path.setdefault(n["path"], []).append(n) - canvas = _comp(by_path[CANVAS][0], "UILiveStartCanvas") - if canvas is None: - raise RuntimeError(f"{CANVAS}: no UILiveStartCanvas") - - # -- strings, and which texts Initialize writes them to --------------------------------------------- - strings, sources = resolve_strings(master, music_id, difficulty, column) - text_of: dict[str, str] = {} - for field, key in TEXT_FIELDS.items(): - refs = canvas[field] if isinstance(canvas[field], list) else [canvas[field]] - for r in refs: - text_of[r["gameObject"]] = strings[key] - - # -- TMP texts of the drawn part: LocalizeText overrides and the characters they show ------------- - styles = textstyle.TextStyles(ex, player, mode, fontsettings.builtin()) if fonts == "open" else None - lang = styles.lang if styles else textstyle.language_fonts(player) - swap = styles.swap if styles else textstyle.font_swap(lang, mode) - fontset = tmpfont.FontSet(ex) if fonts == "game" else None - texts: dict[str, dict] = {} - chars_by_font: dict[str, set] = {} - for n in nodes: - if not _drawn(n["path"]): - continue - t = _comp(n, TMP_CLASS) - if t is None: - continue - if len(by_path[n["path"]]) != 1: - raise RuntimeError(f"{n['path']}: text node path not unique") - lt = _comp(n, "LocalizeText") - if not (lt and lt["m_Enabled"] and lt["_localizeEnabled"]): - raise NotImplementedError(f"{n['path']}: text without an enabled LocalizeText") - loc = textstyle.localize_text( - n["path"], t["m_fontAsset"]["name"], t["m_sharedMaterial"]["material"], swap, lang, mode - ) - shown = text_of[n["path"]] if n["path"] in text_of else t["m_text"] - texts[n["path"]] = {"localized": loc, "text": shown, "setByInitialize": n["path"] in text_of} - if styles: - record = styles.record(n["path"], t, lt) - record.pop("text") # the serialized text; `text` above is the one shown - texts[n["path"]]["style"] = record - chars_by_font.setdefault(loc["fontAsset"], set()).update(ord(ch) for ch in shown) - for p in text_of: - if _drawn(p) and p not in texts: - raise RuntimeError(f"{p}: Initialize writes a text that is not exported") - - if fontset is not None: - coverage, materials, font_out = _game_fonts(ex, fontset, texts, chars_by_font, packer) - else: # name stubs: no font data, no atlases - coverage, materials = None, {} - font_out: dict = {f: {"name": f, "note": FONT_NOTE} for f in sorted(chars_by_font)} - - # -- sprites: drawn Images, the difficulty icons, the jacket ---------------------------------------- - replaced = {r["gameObject"] for f in ("_jacketImages", "_difficultyImages") for r in canvas[f]} - sprite_refs: dict[str, str] = {} # sprite name -> spriteRef - for n in nodes: - im = _comp(n, "Image") - if not _drawn(n["path"]) or im is None or n["path"] in replaced or not im["m_Sprite"]: - continue - sprite_refs.setdefault(im["m_Sprite"]["name"], im["m_Sprite"]["spriteRef"]) - difficulty_icons = {} - for p in canvas["_difficultyPairs"]: - s = p["Icon"] - difficulty_icons[str(p["Difficulty"])] = s["name"] - sprite_refs.setdefault(s["name"], s["spriteRef"]) - music = next(r for r in master_table(master, "MasterLiveMusic") if r["_id"] == music_id) - jacket = ex.key_sprite(JACKET_KEY.format(jacket=music["_jacketAssetName"])) - sprite_refs.setdefault(jacket["name"], jacket["spriteRef"]) - for name, ref in sorted(sprite_refs.items()): - s = ex.sprite_recs[ref] - if s["name"] != name: - raise RuntimeError(f"sprite {name}: record named {s['name']}") - ex.pack_sprite(ref, packer, f"sprites_{_safe(s['tex'].read_typetree()['m_Name'])}") - - textures = packer.build() - if fontset is not None: - tmpfont.resolve_font_blocks(font_out) - sprites_out = {name: ex.packed_sprite(ref) for name, ref in sorted(sprite_refs.items())} - for m in materials.values(): - for v in m["textures"].values(): - if v["texture"] is not None: - v["texture"] = { - "name": v["texture"]["name"], - "width": v["texture"]["width"], - "height": v["texture"]["height"], - "format": v["texture"]["format"], - } - - # -- shaders: livescene's dump (same name and source bundle), GLES3 variant per material ------------ - index = json.loads((out_dir / "livescene" / "shaders" / "shaders.json").read_text(encoding="utf-8")) - by_name = {r["name"]: r for r in index} - keywords = {} - for m in materials.values(): - sh = m["shader"]["shader"] - rec = by_name.get(sh) - if rec is None: - raise RuntimeError(f"{m['material']}: shader {sh} not in livescene/shaders") - if sh not in ex.shaders or ex.shaders[sh].assets_file.name != rec["source"]: - raise RuntimeError(f"{m['material']}: shader {sh} comes from another bundle than livescene's") - known = {k for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3" for k in v["keywords"]} - want = sorted(k for k in m["keywords"] if k in known) - if not any( - v["platform"] == "gles3" - and v["type"] == "GLES3" - and v["stage"] == "vertex" - and sorted(v["keywords"]) == want - for v in rec["variants"] - ): - raise RuntimeError(f"{sh}: no GLES3 variant for material {m['material']} keywords {want}") - keywords[m["material"]] = want - if "UI/Default" not in by_name: - raise RuntimeError("UI/Default not in livescene/shaders") - - # -- the root Animator's controller (evidence) ----------------------------------------------------- - an = _comp(by_path[f"{CANVAS}/root"][0], "Animator") - controller = an["m_Controller"] if an else None - tmp = player.resource("TMP Settings") - tmp_settings = {k: player.names_in(tmp, v) for k, v in player.mono(tmp).items()} - - doc = { - "about": f"Live start canvas (Live/UILiveStartCanvas, LightWeight mode): what livescene/scene.json lacks, " - f"{language}", - "canvas": CANVAS, - "slice": { - "musicId": music_id, - "difficulty": difficulty, - "liveDifficulty": LIVE_DIFFICULTY[difficulty], - "lightweight": True, - "profile": "fresh (no play records)", - }, - "language": { - "mode": mode, - "field": column[1:], - "fonts": lang, - "fontSwap": swap, - "lineSpacing": LANGUAGE_LINE_SPACING[mode], - }, - "strings": strings, - "stringSources": sources, - "texts": texts, - "sprites": sprites_out, - "difficultyIcons": difficulty_icons, - "jacket": jacket["name"], - "textures": textures, - "materials": materials, - "materialKeywords": keywords, - "fonts": font_out, - "glyphCoverage": coverage, - "tmpSettings": { - k: tmp_settings[k] - for k in ( - "m_GetFontFeaturesAtRuntime", - "m_matchMaterialPreset", - "m_enableExtraPadding", - "m_missingGlyphCharacter", - ) - }, - # images draw with Graphic.defaultGraphicMaterial (built-in UI/Default: livescene's dump of it has the - # same variants as the unity_builtin_extra one the ADV exports, checked byte for byte) - "shaders": { - "index": "../livescene/shaders/shaders.json", - "names": sorted({m["shader"]["shader"] for m in materials.values()} | {"UI/Default"}), - }, - "controller": controller, - } - if styles: - doc["fontData"] = "open" - doc["textStyle"] = styles.summary() - write_json(base / "liveui.json", doc) - return { - "liveui": str(base / "liveui.json"), - "texts": len(texts), - "sprites": sorted(sprites_out), - "textures": {k: (v["width"], v["height"]) for k, v in textures.items()}, - "fonts": sorted(font_out), - "coverage": {k: v["counts"] for k, v in (coverage or {}).items()}, - "strings": strings, - "runtimeGlyphs": { - k: len(v["runtimeGlyphs"]["generated"]) for k, v in font_out.items() if v.get("runtimeGlyphs") - }, - } diff --git a/src/nnnotes/content/liveuiproduct.py b/src/nnnotes/content/liveuiproduct.py new file mode 100644 index 0000000..8cb7e49 --- /dev/null +++ b/src/nnnotes/content/liveuiproduct.py @@ -0,0 +1,168 @@ +"""Live start text, font pages and image bindings assembled from object tables.""" + +from __future__ import annotations + +from ..source import languages +from ..unity import textstyle +from ..unity.ids import object_id, parse_object_id +from . import live, liveui +from .prefabs import component_class, component_ref, has_json, key_object, mono_json, ref_id + +FORMAT = "nnnotes.live-ui/1" +TABLES = ("MasterLiveMusic", "MasterLiveMusicScore", "MasterBand", "MasterText") + + +def parse_subject(subject: str) -> tuple[int, str, str]: + """The music id, difficulty and language of //.""" + parts = subject.split("/") + if len(parts) != 3: + raise ValueError(f"{subject!r}: expected //") + music, difficulty, _ = live.parse_subject("/".join(parts[:2])) + return music, difficulty, languages.check(parts[2]) + + +def record(tables: dict, subject: str) -> dict: + """Master rows read by the selected chart's start canvas in one language.""" + music_id, difficulty, code = parse_subject(subject) + music = next(r for r in tables["MasterLiveMusic"] if r["_id"] == music_id) + score = next(r for r in tables["MasterLiveMusicScore"] if r["_id"] == music[f"_{difficulty}ID"]) + column = languages.column(code) + return { + "MasterLiveMusic": [music], + "MasterLiveMusicScore": [score], + "MasterBand": [r for r in tables["MasterBand"] if r["_id"] in music["_bandIDs"]], + "MasterText": [ + {"_id": r["_id"], **{c: r.get(c) or "" for c in {column, languages.column("ja")}}} + for r in tables["MasterText"] + ], + } + + +def keys(master: dict) -> list[str]: + return [liveui.SCENE_KEY, live.jacket_key(master["MasterLiveMusic"][0]["_jacketAssetName"])] + + +def canvas(objects) -> tuple[str, dict, dict]: + """The scene prefab, canvas node and UILiveStartCanvas component.""" + hits = [ + (oid, node, comp) + for oid in objects.key(liveui.SCENE_KEY) + if oid in objects and objects.info(oid)["class"] == "GameObject" and has_json(objects, oid) + for node in objects.json(oid)["nodes"] + if node["path"] == liveui.CANVAS + for comp in node["components"] + if component_class(comp) == "UILiveStartCanvas" + ] + if len(hits) != 1: + raise ValueError(f"{liveui.CANVAS}: expected one UILiveStartCanvas, found {len(hits)}") + return hits[0] + + +def text_records(objects, master: dict, subject: str, settings) -> tuple[dict, dict, dict]: + """Canvas metadata, TMP records by component id and LocalizeManager font data.""" + from . import storyui + + music_id, difficulty, language = parse_subject(subject) + prefab, node, component = canvas(objects) + file, _ = parse_object_id(prefab) + nodes = objects.json(prefab)["nodes"] + fields = component["fields"] + strings, _ = liveui.record_strings(master, music_id, difficulty, languages.column(language)) + lang = textstyle.language_fonts_json(mono_json(objects, storyui.script_root(objects, "LocalizeManager"))) + styles = storyui._Styles(lang, languages.mode(language), settings) + + def target(value): + hit = component_ref(objects, prefab, value) + if hit is None: + raise ValueError("live start canvas requires a component target") + holder, index, comp = hit + return object_id(parse_object_id(holder)[0], int(comp["pathId"])), objects.json(holder)["nodes"][index]["path"] + + initialized = {} + for field, key in liveui.TEXT_FIELDS.items(): + refs = fields[field] if isinstance(fields[field], list) else [fields[field]] + for value in refs: + _, path = target(value) + initialized[path] = key + records = {} + paths = {} + table = {str(r["_id"]): r[languages.column(language)] for r in master["MasterText"]} + for n in nodes: + if not liveui._drawn(n["path"]): + continue + texts = [c for c in n["components"] if component_class(c) in storyui.TEXT_CLASSES] + locs = [c for c in n["components"] if component_class(c) == "LocalizeText"] + if not texts: + continue + if len(texts) != 1 or len(locs) > 1 or n["path"] in paths: + raise ValueError(f"{n['path']}: ambiguous text or localization components") + comp = {"class": component_class(texts[0]), **texts[0]["fields"]} + comp["m_fontAsset"] = {"name": storyui._name(objects, comp["m_fontAsset"])} + comp["m_sharedMaterial"] = {"material": storyui._name(objects, comp["m_sharedMaterial"])} + localize = {"class": "LocalizeText", **locs[0]["fields"]} if locs else None + rec = styles.record(n["path"], comp, localize) + text_key = rec["style"].get("textKey") + shown = ( + strings[initialized[n["path"]]] + if n["path"] in initialized + else (table[str(text_key)] if text_key else comp["m_text"]) + ) + tid = object_id(file, int(texts[0]["pathId"])) + paths[n["path"]] = tid + rec.update(path=n["path"], text=shown, setByInitialize=n["path"] in initialized) + rec["font"] = dict( + rec["localized"] + or { + "fontAsset": comp["m_fontAsset"]["name"], + "material": comp["m_sharedMaterial"]["material"], + "lineSpacing": comp["m_lineSpacing"], + } + ) + records[tid] = rec + missing = [p for p in initialized if liveui._drawn(p) and p not in paths] + if missing: + raise ValueError(f"live start text targets have no TMP records: {missing}") + jacket = key_object(objects, live.jacket_key(master["MasterLiveMusic"][0]["_jacketAssetName"]), "Sprite") + icons = {str(p["Difficulty"]): ref_id(p["Icon"]) for p in fields["_difficultyPairs"]} + icon = icons[str(liveui.LIVE_DIFFICULTY[difficulty])] + if icon is None: + raise ValueError(f"{difficulty}: no difficulty icon") + images = { + target(value)[0]: sprite + for field, sprite in (("_jacketImages", jacket), ("_difficultyImages", icon)) + for value in fields[field] + } + metadata = { + "musicId": music_id, + "difficulty": difficulty, + "scene": {"manifest": "scenes/default.json", "prefab": prefab, "canvas": node["path"]}, + "strings": strings, + "textStyle": styles.summary(), + "images": images, + "jacket": jacket, + "difficultyIcons": icons, + } + return metadata, records, lang + + +def build(objects, master: dict, subject: str, settings, source: str, font_files: dict): + """ui.json, PNG pages and font shader objects for a live start canvas.""" + from .storyfontproduct import font_product + + _, _, language = parse_subject(subject) + metadata, records, lang = text_records(objects, master, subject, settings) + doc, pages, keep, roles = font_product( + objects, + records, + {}, + [r["text"] for r in records.values()], + language, + lang, + settings, + source, + font_files, + format=FORMAT, + subset="live start canvas text, synthesized control characters, underline and missing glyph", + ) + doc.update(metadata) + return doc, pages, keep, roles diff --git a/src/nnnotes/content/prefabs.py b/src/nnnotes/content/prefabs.py new file mode 100644 index 0000000..7d13e3a --- /dev/null +++ b/src/nnnotes/content/prefabs.py @@ -0,0 +1,337 @@ +"""Prefab documents of a product's object table read as the game's hierarchy. + +A root GameObject's `json` is its prefab document (prefab.json): `{name, nodes}`, the nodes in hierarchy order (depth +first), each with its path, local transform, `active` (GameObject.m_IsActive) and components `{type, pathId, script, +fields}`. A component's fields name objects of the table by reference (`{"$ref": {file, pathId}}`) and objects of the +same hierarchy by path: `{"gameObject": path}`, `{"transform": path}`, or `{"component": type, "gameObject": path, +"class": script class}` for a component. `Prefab` gives each node's parent, its world matrix (Unity's left-handed +space, the root's own transform included) and whether it is active in the hierarchy, and resolves those references. + +A component of a hierarchy is in the table as an object contained in its root GameObject (its item `in`): a reference +to it from outside the hierarchy (another root of a scene, an asset) is a `$ref` that `component_ref` resolves. + +What a player reads with the hierarchies of a table: + +key_object the object of a catalog key of a class (a prefab by its GameObject, a MonoBehaviour by its script + class) +controller_clips the clips each Animator of a prefab plays through its controller (Animator.m_Controller, the + controller.json `clips`), by the Animator's node path +clip_bindings the bindings of the clips each Animator plays, named in the prefab from the Animator's node + (unity.bindings.resolve) +shader_table the files a player draws each shader of the table with: its parsed form (`json`), its variant + index (`index`) and every program of the player's graphics API (formats.shader.player_roles); and + what a lookup by name (Shader.Find) finds, by name (a material names its shader by id) +mesh_roles the files a player draws each Mesh of a product with: its typetree JSON (`json`) and its `arrays` + document +""" + +from __future__ import annotations + +from collections.abc import Iterable, Iterator, Mapping +from typing import TYPE_CHECKING, Any, Protocol + +from ..formats.shader import player_roles +from ..unity import bindings +from ..unity.ids import object_id + +if TYPE_CHECKING: + import numpy as np + +__all__ = [ + "Objects", + "Prefab", + "builtin_root", + "clip_bindings", + "component_class", + "component_ref", + "controller_clips", + "has_json", + "key_object", + "mat4", + "mesh_roles", + "mono_json", + "prefab_ids", + "ref_id", + "root_game_object", + "script_class", + "shader_table", +] + + +class Objects(Protocol): + """A product's object table and its objects' files (pipeline.assemble.Objects).""" + + roots: list[str] + + def __contains__(self, oid: str) -> bool: ... + + def ids(self, cls: str | None = None) -> list[str]: ... + + def key(self, key: str) -> list[str]: ... + + def keys(self) -> list[str]: ... + + def info(self, oid: str) -> dict: ... + + def fenced(self, oid: str) -> dict | None: ... + + def bytes(self, oid: str, role: str) -> bytes: ... + + def json(self, oid: str, role: str = "json") -> Any: ... + + +def ref_id(v) -> str | None: + """The object id of a reference value ({"$ref": {file, pathId}}); None for anything else.""" + r = v.get("$ref") if isinstance(v, dict) else None + return object_id(r["file"], r["pathId"]) if isinstance(r, dict) and "file" in r else None + + +def component_class(c: dict) -> str: + """A component's class: a MonoBehaviour's is its script's (the last part of its script entry).""" + if c["type"] == "MonoBehaviour" and isinstance(c.get("script"), str): + return c["script"].rsplit("|", 1)[-1] + return c["type"] + + +def has_json(objects: Objects, oid: str) -> bool: + """`oid` is an object of the table with a `json` file.""" + return oid in objects and "json" in (objects.info(oid).get("files") or {}) + + +def script_class(objects: Objects, oid: str) -> str | None: + """The script class of a MonoBehaviour or a contained component, None without one.""" + if oid in objects and objects.info(oid).get("in"): + file, pid = oid.rsplit(":", 1) + hit = component_ref(objects, "", {"$ref": {"file": file, "pathId": int(pid)}}) + return component_class(hit[2]) if hit is not None else None + if not has_json(objects, oid): + return None + entry = objects.json(oid).get("$script") + return entry.rsplit("|", 1)[-1] if isinstance(entry, str) else None + + +def mono_json(objects: Objects, oid: str) -> dict: + """The serialized fields of a MonoBehaviour, including a component stored in a prefab's document.""" + if objects.info(oid).get("in"): + file, pid = oid.rsplit(":", 1) + hit = component_ref(objects, "", {"$ref": {"file": file, "pathId": int(pid)}}) + if hit is None or hit[2]["type"] != "MonoBehaviour": + raise ValueError(f"{oid}: not a MonoBehaviour component") + return {"$script": hit[2].get("script"), **hit[2]["fields"]} + return objects.json(oid) + + +def root_game_object(objects: Objects, what: str) -> str: + """The one GameObject among the table's roots (ValueError naming `what` otherwise).""" + gos = [o for o in objects.roots if o in objects and objects.info(o).get("class") == "GameObject"] + if len(gos) != 1: + raise ValueError(f"{what}: {len(gos)} GameObjects among the roots, expected one") + return gos[0] + + +def builtin_root(objects: Objects, cls: str) -> str: + """The one root of class `cls` (a built-in setting: GraphicsSettings, QualitySettings, PlayerSettings).""" + hits = [o for o in objects.roots if o in objects and objects.info(o).get("class") == cls] + if len(hits) != 1: + raise ValueError(f"{len(hits)} {cls} roots") + return hits[0] + + +def key_object(objects: Objects, key: str, cls: str) -> str: + """The one object of the catalog key `key` of class `cls`: a GameObject with its prefab document, a MonoBehaviour + by its script class, any other object by its class (ValueError otherwise).""" + + def of(oid: str) -> str | None: + c = objects.info(oid).get("class") + if c == "GameObject": + return c if has_json(objects, oid) else None + return script_class(objects, oid) if c == "MonoBehaviour" else c + + hits = [o for o in objects.key(key) if o in objects and of(o) == cls] + if len(hits) != 1: + raise ValueError(f"{key}: {len(hits)} {'prefab GameObjects' if cls == 'GameObject' else cls + ' objects'}") + return hits[0] + + +def component_ref(objects: Objects, prefab: str, v) -> tuple[str, int, dict] | None: + """(prefab GameObject id, node, component) that a component field of the hierarchy of GameObject `prefab` names: + a component of the same hierarchy by path, or a `$ref` to a component of another hierarchy of the table; None for + null (ValueError for a reference to anything else).""" + if v is None: + return None + oid = ref_id(v) + if oid is None: + hit = Prefab(objects.json(prefab)).component_of(v) + return None if hit is None else (prefab, *hit) + holder = objects.info(oid).get("in") if oid in objects else None + if holder is None or not has_json(objects, holder): + raise ValueError(f"{oid}: not a component of a hierarchy of the table") + pid = oid.rsplit(":", 1)[1] + for i, n in enumerate(objects.json(holder)["nodes"]): + for c in n["components"]: + if c.get("pathId") == pid: + return holder, i, c + raise ValueError(f"{oid}: no component of {holder}") + + +def prefab_ids(objects: Objects) -> list[str]: + """The GameObjects of the table with a prefab document (the roots of their hierarchies), sorted.""" + return [o for o in objects.ids("GameObject") if has_json(objects, o)] + + +def controller_clips(objects: Objects, prefab: dict) -> dict[str, list[str]]: + """{node path: clip ids} of every Animator of a prefab document whose controller is in the table: the clips its + controller plays, in the controller's order.""" + out: dict[str, list[str]] = {} + for n in prefab["nodes"]: + for c in n["components"]: + ctl = ref_id(c["fields"].get("m_Controller")) if c["type"] == "Animator" else None + if ctl is not None and has_json(objects, ctl): + clips = [ref_id(r) for r in objects.json(ctl).get("clips", ())] + out[n["path"]] = [o for o in clips if o is not None] + return out + + +def clip_bindings(objects: Objects, prefab: dict, plays: Mapping[str, Iterable[str]]) -> dict[str, dict[str, list]]: + """{node path: {clip id: [{path, attribute}]}} of the clips `plays` gives each Animator of a prefab document + ({the Animator's node path: clip ids}), named in the prefab from the Animator's node (unity.bindings.resolve). A + clip without its `json` in the table is left out, and so is an Animator left without clips. A path in `plays` that + holds no Animator raises.""" + + def material(ref) -> dict | None: + oid = ref_id(ref) + return objects.json(oid) if oid is not None and has_json(objects, oid) else None + + animators = {n["path"] for n in prefab["nodes"] for c in n["components"] if c["type"] == "Animator"} + out = {} + for path, clips in sorted(plays.items()): + if path not in animators: + raise ValueError(f"{prefab['name']}: no Animator at {path!r}") + table = {} + for cid in clips: + if cid not in table and has_json(objects, cid): + table[cid] = bindings.resolve(objects.json(cid)["bindings"], prefab, path, material) + if table: + out[path] = dict(sorted(table.items())) + return out + + +def shader_table(objects: Objects, found: Iterable[str] = ()) -> tuple[dict[str, str], dict[str, set[str]]]: + """({shader name: object id}, {object id: the roles of its files a player reads}) of the Shaders of the table that + have their variant index (module documentation). The name table gives what a lookup by name finds. Bundles may + each hold a copy of a shader: Shaders of one name whose parsed form and index are the same content are one shader, + named by the first id. Of different Shaders of one name the lookup finds the one of `found` (the shaders it finds + first: GraphicsSettings' always-included shaders); without one, the name names none of them.""" + groups: dict[str, list[str]] = {} + roles: dict[str, set[str]] = {} + first = set(found) + + def content(sid: str) -> tuple: + files = objects.info(sid)["files"] + return tuple(files[r]["sha256"] for r in ("json", "index")) + + for sid in objects.ids("Shader"): + if "index" not in (objects.info(sid).get("files") or {}): + continue + index = objects.json(sid, "index") + groups.setdefault(index["name"], []).append(sid) + roles[sid] = {"json", "index"} | player_roles(index["variants"]) + names = {} + for name, sids in sorted(groups.items()): + pick = sids if len({content(s) for s in sids}) == 1 else [s for s in sids if s in first] + if pick and len({content(s) for s in pick}) == 1: + names[name] = pick[0] + return names, roles + + +def mesh_roles(objects: Objects, keep: Iterable[str]) -> dict[str, set[str]]: + """{Mesh id: {"json", "arrays"}} of the Meshes of `keep` that have their `arrays` document (module documentation). + A Mesh without one (it converted as generic JSON) keeps all its files.""" + kept = set(keep) + out = {} + for mid in objects.ids("Mesh"): + if mid in kept and "arrays" in (objects.info(mid).get("files") or {}): + out[mid] = {"json", "arrays"} + return out + + +def mat4(m: np.ndarray) -> list[float]: + """A 4x4 matrix as 16 floats, column-major.""" + return [float(x) for x in m.T.reshape(-1)] + + +class Prefab: + """The nodes of a prefab document with their parents, world matrices and activity (module documentation).""" + + def __init__(self, doc: dict): + self.name: str = doc["name"] + self.nodes: list[dict] = doc["nodes"] + self.parent: list[int | None] = [] + stack: list[int] = [] + for n in self.nodes: + while stack and not n["path"].startswith(self.nodes[stack[-1]]["path"] + "/"): + stack.pop() + self.parent.append(stack[-1] if stack else None) + stack.append(len(self.parent) - 1) + self._by_path: dict[str, list[int]] = {} + for i, n in enumerate(self.nodes): + self._by_path.setdefault(n["path"], []).append(i) + self._world: dict[int, np.ndarray] = {} # numpy is imported when a world matrix is asked for + + def at(self, path: str) -> int: + """The node at `path` (ValueError when no node, or several, has it).""" + hits = self._by_path.get(path, []) + if len(hits) != 1: + raise ValueError(f"{self.name}: {len(hits)} nodes at {path!r}") + return hits[0] + + def world(self, i: int) -> np.ndarray: + """The world matrix of node `i`: its local transform under its parents'.""" + from ..unity.transform import trs + + if i not in self._world: + n = self.nodes[i] + m = trs(n["localPosition"], n["localRotation"], n["localScale"]) + p = self.parent[i] + self._world[i] = m if p is None else self.world(p) @ m + return self._world[i] + + def active(self, i: int | None) -> bool: + """Node `i` and every parent of it are active.""" + while i is not None: + if not self.nodes[i].get("active", True): + return False + i = self.parent[i] + return True + + def components(self, cls: str) -> Iterator[tuple[int, dict]]: + """(node, component) of every component of class `cls`, in hierarchy order.""" + for i, n in enumerate(self.nodes): + for c in n["components"]: + if component_class(c) == cls: + yield i, c + + def node_of(self, v) -> int | None: + """The node a reference to an object of the hierarchy names (its GameObject, Transform or a component of + it); None for null.""" + if v is None: + return None + if not isinstance(v, dict) or not ({"gameObject", "transform"} & v.keys()): + raise ValueError(f"{self.name}: not a reference to an object of the hierarchy: {v!r}") + return self.at(v["gameObject"] if "gameObject" in v else v["transform"]) + + def component_of(self, v) -> tuple[int, dict] | None: + """(node, component) a reference to a component of the hierarchy names; None for null.""" + i = self.node_of(v) + if i is None: + return None + if "component" not in v: + raise ValueError(f"{self.name}: not a reference to a component: {v!r}") + hits = [ + c + for c in self.nodes[i]["components"] + if c["type"] == v["component"] and ("class" not in v or component_class(c) == v["class"]) + ] + if len(hits) != 1: + raise ValueError(f"{self.name}: {len(hits)} components {v!r}") + return i, hits[0] diff --git a/src/nnnotes/content/room.py b/src/nnnotes/content/room.py index e8ba1ac..8dfde11 100644 --- a/src/nnnotes/content/room.py +++ b/src/nnnotes/content/room.py @@ -1,49 +1,45 @@ -"""Spot background prefab -> binary glTF (.glb). - -A spot background is a few hundred textured cards (MeshFilter + MeshRenderer -under a Transform hierarchy). Each mesh is baked into prefab space by walking -the Transform TRS chain, converted from Unity's left-handed space to glTF's -right-handed space (negate Z, reverse winding), and written with its texture(s) -embedded. Geometry is read from the mesh's vertex/index buffers; each submesh -becomes one primitive with the renderer's material of the same index. - -Objects are told apart by (serialized file name, path id) (object_key): a path -id is unique only within its file, and a bundle's closure loads several files. -Each Unity texture is one glTF texture with its own sampler; textures whose PNG -bytes are equal share one glTF image. - -Materials are translated from the Unity material's shader and that shader's own -pass render state (culling, blend factors and operations, depth write and test, -colour mask, alpha to mask, queue tags) -- not from names. A render state value -that names a property (URP/Lit's `Blend [_SrcBlend] [_DstBlend]`, `Cull [_Cull]`, -`ZWrite [_ZWrite]`) takes the material's value of that property, else the -shader's default for it, else the value the shader stores (render_state). Each -glTF material records the resolved state in `extras.unityRenderState`. -Unsupported shaders and states raise instead of being approximated. A submesh -whose material slot is empty (null) is not written: nothing says what it would -be drawn with; room.json lists it (`nullMaterialSubmeshes`). A MeshFilter with -no mesh, or a mesh with no triangles, draws nothing; room.json lists the object -(`nullMeshFilters`, `meshesWithoutTriangles`). - -The situation's backgroundPosition/Rotation/Scale (spot.json) is not baked in: it -goes on top of this, as the game applies it to the prefab root. Objects that -are inactive in the prefab are kept, flagged `extras.unityActive = false`. +"""A spot background (a room) assembled from the objects its prefab reaches: binary glTF (.glb) and its summary. + +A spot background is a few hundred textured cards: MeshFilter and MeshRenderer components under a Transform +hierarchy. `room` reads them from the prefab document of the background's GameObject (content.prefabs) and the +objects its components name: each Mesh's `mesh` (binary glTF in the mesh's local space, unity.atoms.gltf), each +Material's `json` and its Shader's parsed form, each Texture2D's `json` and `image`. It never reads a bundle. + +Each drawn mesh (a MeshFilter with a mesh beside an enabled MeshRenderer) becomes one node without a transform: its +vertices are baked into the prefab's space through the world matrix of its node (the root's own transform included), +in glTF's right-handed space (z negated), with Unity's texture coordinates flipped (v = 1 - v), as the mesh's glTF +holds them. Each non-empty submesh becomes one primitive with the renderer's material of the same index. A node +inactive in the prefab is kept, flagged `extras.unityActive = false`. + +Materials are translated from the Unity material's shader and that shader's own pass render state (culling, blend +factors and operations, depth write and test, colour mask, alpha to mask, queue tags), not from names. A render +state value that names a property (URP/Lit's `Blend [_SrcBlend] [_DstBlend]`, `Cull [_Cull]`, `ZWrite [_ZWrite]`) +takes the material's value of that property, else the shader's default for it, else the value the shader stores +(render_state). Each glTF material records the resolved state in `extras.unityRenderState`. Unsupported shaders and +states raise instead of being approximated. Each Texture2D a material's main slot names is one glTF texture with its +own sampler; textures whose PNG bytes are equal share one glTF image. + +What draws nothing is not written and room.json lists it: a submesh whose material slot is empty +(`nullMaterialSubmeshes`), a MeshFilter without a mesh (`nullMeshFilters`), a mesh without vertices or triangles +(`emptyMeshes`). + +The situation's backgroundPosition, backgroundRotation and backgroundScale (spot.json) are not baked in: they go on +top of this, as the game applies them to the prefab root. """ from __future__ import annotations import hashlib -import io +import re import struct -from pathlib import Path -import numpy as np -import UnityPy +from ..core.jsonio import dumps +from .prefabs import Objects, Prefab, component_class, ref_id, root_game_object -from ..core.jsonio import dumps, write_json -from ..source.catalog import Catalog -from ..unity.reader import SceneGraph, deref, mesh_arrays, referent, texture_image +__all__ = ["BACKGROUND_KEY", "room", "room_name", "render_state", "translate_material"] +# a spot background's catalog key: Spot//Background/ +BACKGROUND_KEY = r"Spot/(?P[^/]+)/Background/(?P=place)" # UnityEngine.Rendering.BlendMode BLEND_ONE, BLEND_ZERO, BLEND_SRC_ALPHA, BLEND_ONE_MINUS_SRC_ALPHA = 1, 0, 5, 10 BLEND_OP_ADD = 0 # UnityEngine.Rendering.BlendOp.Add @@ -55,7 +51,7 @@ # UnityEngine.TextureWrapMode WRAP = {0: REPEAT, 1: CLAMP, 2: MIRROR, 3: MIRROR} UNLIT_SHADERS = ("Unlit/", "Universal Render Pipeline/Unlit") -# render state of a pass (SerializedShaderState) read by room: record key -> path in m_State (render target 0) +# render state of a pass (SerializedShaderState) read by room: record key -> path in the pass state (render target 0) STATE_FIELDS = { "srcBlend": ("rtBlend0", "srcBlend"), "dstBlend": ("rtBlend0", "destBlend"), @@ -70,19 +66,28 @@ "alphaToMask": ("alphaToMask",), } NO_PROPERTY = "" # SerializedShaderFloatValue.name of a fixed value +TRIANGLES = 4 # glTF primitive mode + + +def room_name(key: str) -> str: + """The of a background key (ValueError for another key).""" + m = re.fullmatch(BACKGROUND_KEY, key) + if m is None: + raise ValueError(f"{key}: not a spot background key (Spot//Background/)") + return m["place"] # ---- material translation ------------------------------------------------- -def shader_defaults(shader_tt: dict) -> dict: - """{property name: default value} of a shader's properties (m_ParsedForm.m_PropInfo, first component).""" - props = shader_tt["m_ParsedForm"].get("m_PropInfo", {}).get("m_Props", []) - return {p["m_Name"]: p["m_DefValue[0]"] for p in props if "m_DefValue[0]" in p} +# A material is its `json` (material.json: material, shader, keywords, floats, colors, ints, textures), a shader its +# parsed form (name, properties, subShaders[].tags, subShaders[].passes[].tags and state). +def shader_defaults(shader: dict) -> dict: + """{property name: default value} of a shader's properties (the first component).""" + return {p["m_Name"]: p["m_DefValue[0]"] for p in shader.get("properties") or () if "m_DefValue[0]" in p} -def material_values(mat_tt: dict) -> dict: +def material_values(material: dict) -> dict: """{property name: value} of a material's int and float properties.""" - sp = mat_tt["m_SavedProperties"] - return {**dict(sp.get("m_Ints", [])), **dict(sp.get("m_Floats", []))} + return {**(material.get("ints") or {}), **(material.get("floats") or {})} def state_value(x, values: dict, defaults: dict): @@ -98,10 +103,19 @@ def state_value(x, values: dict, defaults: dict): return int(v) if isinstance(v, float) and v.is_integer() else v -def render_state(mat_tt: dict, shader_tt: dict) -> dict: +def _pass_state(shader: dict) -> tuple[dict, dict]: + """(render state of the first pass, merged subshader and pass tags).""" + ss = shader["subShaders"][0] + ps = ss["passes"][0] + tags = dict((ss.get("tags") or {}).get("tags", [])) + tags.update(dict((ps.get("tags") or {}).get("tags", []))) + return ps["state"], tags + + +def render_state(material: dict, shader: dict) -> dict: """The render state (STATE_FIELDS) of the shader's first pass for this material.""" - state, _ = _pass_state(shader_tt) - values, defaults = material_values(mat_tt), shader_defaults(shader_tt) + state, _ = _pass_state(shader) + values, defaults = material_values(material), shader_defaults(shader) out = {} for key, path in STATE_FIELDS.items(): x = state @@ -111,29 +125,20 @@ def render_state(mat_tt: dict, shader_tt: dict) -> dict: return out -def _pass_state(shader_tt: dict) -> tuple[dict, dict]: - """(render state of the first pass, merged subshader+pass tags).""" - ss = shader_tt["m_ParsedForm"]["m_SubShaders"][0] - ps = ss["m_Passes"][0] - tags = dict(ss.get("m_Tags", {}).get("tags", [])) - tags.update(dict(ps.get("m_Tags", {}).get("tags", []))) - return ps["m_State"], tags - - -def translate_material(mat_tt: dict, shader_tt: dict) -> dict: - """Unity material + shader -> glTF material fields (texture filled by caller).""" - shader_name = shader_tt["m_ParsedForm"]["m_Name"] - _, tags = _pass_state(shader_tt) - rs = render_state(mat_tt, shader_tt) +def translate_material(material: dict, shader: dict) -> dict: + """A Unity material and its shader -> glTF material fields (the texture is the caller's).""" + shader_name = shader["name"] + _, tags = _pass_state(shader) + rs = render_state(material, shader) src, dst, cull = rs["srcBlend"], rs["dstBlend"], rs["cull"] - floats = dict(mat_tt["m_SavedProperties"]["m_Floats"]) - colors = dict(mat_tt["m_SavedProperties"]["m_Colors"]) + floats = material.get("floats") or {} + colors = material.get("colors") or {} unlit = shader_name.startswith(UNLIT_SHADERS) if not unlit and shader_name != "Universal Render Pipeline/Lit": raise NotImplementedError(f"shader not translated: {shader_name}") - out: dict = {"name": mat_tt["m_Name"], "extras": {"unityShader": shader_name, "unityRenderState": rs}} + out: dict = {"name": material["material"], "extras": {"unityShader": shader_name, "unityRenderState": rs}} if (src, dst) == (BLEND_ONE, BLEND_ZERO): cutout = tags.get("RenderType") == "TransparentCutout" or tags.get("QUEUE") == "AlphaTest" if cutout: @@ -164,29 +169,27 @@ def translate_material(mat_tt: dict, shader_tt: dict) -> dict: return out -def _main_tex_slot(shader_name: str) -> str: +def main_tex_slot(shader_name: str) -> str: + """The texture property a shader samples as its main texture.""" return "_BaseMap" if shader_name.startswith("Universal Render Pipeline/") else "_MainTex" -def object_key(obj) -> tuple[str, int]: - """(serialized file name, path id) of an object: a path id is unique only within its file.""" - return obj.assets_file.name, obj.path_id - +def texture_info(index: int, scale: dict, offset: dict) -> tuple[dict, bool]: + """(glTF texture info of texture `index`, whether it uses KHR_texture_transform) for a texture slot's scale and + offset.""" + info: dict = {"index": index} + sx, sy, ox, oy = scale["x"], scale["y"], offset["x"], offset["y"] + if (sx, sy, ox, oy) == (1.0, 1.0, 0.0, 0.0): + return info, False + # Unity samples (u*sx+ox, v*sy+oy) with a bottom-left origin; in glTF's top-left texture space that is this + info["extensions"] = {"KHR_texture_transform": {"scale": [sx, sy], "offset": [ox, 1.0 - sy - oy]}} + return info, True -def main_textures(owner, mat_tt: dict, shader_name: str) -> list: - """[(texture object, texture env)] of a material's main texture slot (_main_tex_slot) when it holds a texture; - `owner` is the material's object, whose file the env's PPtr is read from.""" - slot = _main_tex_slot(shader_name) - return [ - (deref(owner, env["m_Texture"]), env) - for key, env in mat_tt["m_SavedProperties"]["m_TexEnvs"] - if key == slot and env["m_Texture"]["m_PathID"] - ] - -def _sampler(tex_tt: dict) -> dict: - s = tex_tt.get("m_TextureSettings", {}) - mips = tex_tt.get("m_MipCount", 1) > 1 +def sampler(texture: dict) -> dict: + """The glTF sampler of a Texture2D's settings and mip count (its typetree fields).""" + s = texture.get("m_TextureSettings", {}) + mips = texture.get("m_MipCount", 1) > 1 fm = s.get("m_FilterMode", 1) mag = NEAREST if fm == 0 else LINEAR if fm == 0: @@ -202,7 +205,9 @@ def _sampler(tex_tt: dict) -> dict: # ---- glTF builder ------------------------------------------------------- -class _Glb: +class Glb: + """A binary glTF of baked meshes: one node per mesh, one primitive per submesh.""" + def __init__(self): self.bin = bytearray() self.bufferViews, self.accessors, self.meshes, self.nodes = [], [], [], [] @@ -220,7 +225,9 @@ def _view(self, data: bytes, target=None) -> int: self.bufferViews.append(bv) return len(self.bufferViews) - 1 - def accessor_f32(self, arr: np.ndarray, kind: str, minmax=False) -> int: + def accessor_f32(self, arr, kind: str, minmax=False) -> int: + import numpy as np + a = np.ascontiguousarray(arr, dtype=" int: self.accessors.append(acc) return len(self.accessors) - 1 - def accessor_idx(self, idx: np.ndarray) -> int: + def accessor_idx(self, idx) -> int: + import numpy as np + a = np.ascontiguousarray(idx, dtype=" None: attrs["NORMAL"] = self.accessor_f32(nrm, "VEC3") primitives = [] for idx, material in prims: - p = {"attributes": attrs, "indices": self.accessor_idx(idx), "mode": 4} + p = {"attributes": attrs, "indices": self.accessor_idx(idx), "mode": TRIANGLES} if material is not None: p["material"] = material primitives.append(p) self.meshes.append({"name": name, "primitives": primitives}) self.nodes.append({"name": name, "mesh": len(self.meshes) - 1, "extras": {"unityActive": active}}) - def write(self, path: Path, extras: dict): + def bytes(self, extras: dict) -> bytes: """glTF requires the arrays it has to be non-empty and a buffer to hold bytes: an empty array is left out, and a room without geometry is a scene without nodes and a file without the BIN chunk.""" scene = {"nodes": list(range(len(self.nodes)))} if self.nodes else {} @@ -300,166 +309,177 @@ def write(self, path: Path, extras: dict): gltf["extensionsUsed"] = sorted(self.ext_used) js = dumps(gltf, separators=(",", ":")).encode("utf-8") js += b" " * (-len(js) % 4) - self.bin.extend(b"\0" * (-len(self.bin) % 4)) - size = 12 + 8 + len(js) + (8 + len(self.bin) if self.bin else 0) - with open(path, "wb") as f: - f.write(struct.pack(" list: - """[(glTF triangle indices, material index)] of a mesh's submeshes: `tris` (per submesh, Unity winding), - `mats` the renderer's material PPtrs of the same index. An empty submesh gives nothing; a submesh whose material - slot is empty (path id 0) gives nothing and is reported to `on_null(submesh index)`.""" - prims = [] - for i, t in enumerate(tris): - if not len(t): - continue - if not mats[i].path_id: # empty slot: no material to draw the submesh with - on_null(i) - continue - prims.append((t[:, [0, 2, 1]].reshape(-1).astype(np.uint32), material_for(mats[i]))) - return prims - - -def mesh_components(env) -> tuple[dict, dict]: - """({GameObject key: MeshFilter}, {GameObject key: MeshRenderer}) of an environment. The key is (file name, - GameObject path id), as object_key: a component is in its GameObject's file.""" - mf_by_go, mr_by_go = {}, {} - for o in env.objects: - if o.type.name == "MeshFilter": - mf_by_go[(o.assets_file.name, o.read_typetree()["m_GameObject"]["m_PathID"])] = o - elif o.type.name == "MeshRenderer": - mr_by_go[(o.assets_file.name, o.read_typetree()["m_GameObject"]["m_PathID"])] = o - return mf_by_go, mr_by_go - - -def drawn_meshes(mf_by_go: dict, mr_by_go: dict, graph, on_null_mesh): - """(transform path id, Mesh, material PPtrs) of each GameObject with a MeshFilter and an enabled MeshRenderer - (mesh_components maps). A MeshFilter whose mesh reference is empty (path id 0) draws nothing: its object path - goes to `on_null_mesh(path)`.""" - for key, mf in mf_by_go.items(): - tf_pid = graph.tf_of_go.get(key[1]) - mr = mr_by_go.get(key) - if tf_pid is None or mr is None: + body = bytes(self.bin) + b"\0" * (-len(self.bin) % 4) + size = 12 + 8 + len(js) + (8 + len(body) if body else 0) + out = struct.pack(" dict: + """The materials, textures and samplers as room.json lists them.""" + return { + "materials": [ + { + "name": m["name"], + "shader": m["extras"]["unityShader"], + "alphaMode": m.get("alphaMode", "OPAQUE"), + "alphaCutoff": m.get("alphaCutoff"), + "doubleSided": m.get("doubleSided", False), + "unlit": "KHR_materials_unlit" in m.get("extensions", {}), + "renderState": m["extras"]["unityRenderState"], + } + for m in self.materials + ], + "textures": [t["name"] for t in self.textures], + "samplers": self.samplers, + } + + +# ---- assembly ------------------------------------------------------------- +def _floats(doc: dict, body: bytes, index: int): + """The values of a glTF accessor as float32, a normalized integer accessor dequantized.""" + import numpy as np + + from ..unity.atoms.gltf import accessor_array + + a = accessor_array(doc, body, index) + if doc["accessors"][index].get("normalized"): + return (a.astype(np.float64) / np.iinfo(a.dtype).max).astype(np.float32) + return a.astype(np.float32) + + +class _Mesh: + """A Mesh object's local-space glTF: positions and normals back in Unity's space (z as the game stores it), + texture coordinates as the glTF holds them (v = 1 - v), and per submesh its triangle indices (glTF winding) or + None for an empty submesh; no submeshes for a mesh without an index buffer (one points primitive).""" + + def __init__(self, objects: Objects, oid: str): + from ..unity.atoms.gltf import accessor_array, read + + doc, body = read(objects.bytes(oid, "mesh")) + (mesh,) = doc["meshes"] + prims = mesh["primitives"] + attrs = prims[0]["attributes"] + self.name = mesh.get("name") or objects.info(oid).get("name") or oid + self.pos = _floats(doc, body, attrs["POSITION"])[:, :3].copy() + self.pos[:, 2] *= -1.0 + self.nrm = _floats(doc, body, attrs["NORMAL"])[:, :3].copy() if "NORMAL" in attrs else None + if self.nrm is not None: + self.nrm[:, 2] *= -1.0 + self.uv = _floats(doc, body, attrs["TEXCOORD_0"])[:, :2] if "TEXCOORD_0" in attrs else None + unity = (mesh.get("extras") or {}).get("unity") or {} + empty = set(unity.get("emptySubmeshes") or ()) + count = len(unity.get("submeshes") or ()) + self.submeshes: list = [] + if any("indices" not in p for p in prims): + return + it = iter(prims) + for i in range(count): + if i in empty: + self.submeshes.append(None) + continue + p = next(it) + if p.get("mode", TRIANGLES) != TRIANGLES or "indices" not in p: + raise NotImplementedError(f"mesh {self.name!r}: submesh {i} is not a triangle list") + self.submeshes.append(accessor_array(doc, body, p["indices"]).reshape(-1)) + if next(it, None) is not None: + raise ValueError(f"mesh {self.name!r}: more primitives than non-empty submeshes") + + +def room(objects: Objects, key: str) -> tuple[bytes, dict]: + """(room.glb, room.json) of the background `key` (module documentation).""" + import numpy as np + + prefab = Prefab(objects.json(root_game_object(objects, key))) + glb = Glb() + textures: dict[str, int] = {} # Texture2D object id -> glTF texture + materials: dict[str, int] = {} # Material object id -> glTF material + + def material_for(oid: str) -> int: + if oid in materials: + return materials[oid] + mat = objects.json(oid) + sid = ref_id(mat["shader"]) + if sid is None or sid not in objects: + raise ValueError(f"{key}: material {mat['material']!r} has no shader in the object table") + shader = objects.json(sid) + gm = translate_material(mat, shader) + slot = (mat.get("textures") or {}).get(main_tex_slot(shader["name"])) + tid = ref_id(slot["texture"]) if slot else None + if slot and tid is not None: + if tid not in textures: + tex = objects.json(tid) + textures[tid] = glb.texture(objects.bytes(tid, "image"), tex["m_Name"], sampler(tex)) + info, transform = texture_info(textures[tid], slot["scale"], slot["offset"]) + if transform: + glb.ext_used.add("KHR_texture_transform") + gm["pbrMetallicRoughness"]["baseColorTexture"] = info + materials[oid] = glb.material(gm) + return materials[oid] + + counts = {"meshes": 0, "inactive": 0} + null_slots, null_meshes, empty, nodes = [], [], [], [] + for i, node in enumerate(prefab.nodes): + comps = {component_class(c): c["fields"] for c in node["components"]} + mf, mr = comps.get("MeshFilter"), comps.get("MeshRenderer") + if mf is None or mr is None or not mr.get("m_Enabled", 1): continue - if not mr.read_typetree().get("m_Enabled", 1): + path = node["path"] + mesh_id = ref_id(mf.get("m_Mesh")) + if mesh_id is None: + null_meshes.append(path) continue - mesh_ref = mf.read().m_Mesh - if not mesh_ref.path_id: - on_null_mesh(graph.path(tf_pid)) + info = objects.info(mesh_id) + if "mesh" not in (info.get("files") or {}): + if info.get("reason") != "empty.mesh": + raise ValueError(f"{key}: {path}: mesh {info.get('name')!r} was not converted ({info.get('reason')})") + empty.append({"mesh": info.get("name"), "path": path}) continue - yield tf_pid, mesh_ref.read(), mr.read().m_Materials - - -def extract_room(cat: Catalog, bg_key: str, out_path: Path) -> dict: - env = UnityPy.load(*[str(p) for p in cat.fetch_key(bg_key)]) - graph = SceneGraph(env) - mf_by_go, mr_by_go = mesh_components(env) - - glb = _Glb() - tex_cache: dict[tuple[str, int], int] = {} # texture object_key -> glTF texture - mat_cache: dict[tuple[str, int], int] = {} # material object_key -> glTF material - - def material_for(pptr) -> int: - mat = pptr.deref() - mkey = object_key(mat) - if mkey in mat_cache: - return mat_cache[mkey] - mat_tt = mat.read_typetree() - shader_tt = referent(mat, mat_tt["m_Shader"]).read_typetree() - gm = translate_material(mat_tt, shader_tt) - for tex_obj, env_ in main_textures(mat, mat_tt, shader_tt["m_ParsedForm"]["m_Name"]): - tid = object_key(tex_obj) - if tid not in tex_cache: - tex_tt = tex_obj.read_typetree() - buf = io.BytesIO() - texture_image(tex_obj.read()).save(buf, format="PNG") - tex_cache[tid] = glb.texture(buf.getvalue(), tex_tt["m_Name"], _sampler(tex_tt)) - info: dict = {"index": tex_cache[tid]} - sx, sy = env_["m_Scale"]["x"], env_["m_Scale"]["y"] - ox, oy = env_["m_Offset"]["x"], env_["m_Offset"]["y"] - if (sx, sy, ox, oy) != (1.0, 1.0, 0.0, 0.0): - # Unity samples (u*sx+ox, v*sy+oy) with a bottom-left origin; - # in glTF's top-left texture space that is this transform. - info["extensions"] = {"KHR_texture_transform": {"scale": [sx, sy], "offset": [ox, 1.0 - sy - oy]}} - glb.ext_used.add("KHR_texture_transform") - gm["pbrMetallicRoughness"]["baseColorTexture"] = info - mat_cache[mkey] = glb.material(gm) - return mat_cache[mkey] - - n_mesh = 0 - n_inactive = 0 - skipped = [] - null_slots = [] # submeshes whose material slot is empty - null_meshes = [] # objects whose MeshFilter has no mesh - no_triangles = [] # meshes with vertices but no triangles - for tf_pid, mesh, mats in drawn_meshes(mf_by_go, mr_by_go, graph, null_meshes.append): - arrays = mesh_arrays(mesh) - if arrays is None: - skipped.append(mesh.m_Name) # empty mesh (no vertex data) + mesh = _Mesh(objects, mesh_id) + if not any(tri is not None and len(tri) for tri in mesh.submeshes): + empty.append({"mesh": mesh.name, "path": path}) continue - v, vn, uv0, tris = arrays - if not any(len(t) for t in tris): # vertices only (e.g. a placeholder mesh without index data) - no_triangles.append({"mesh": mesh.m_Name, "path": graph.path(tf_pid)}) + mats = mr.get("m_Materials") or [] + if len(mats) < len(mesh.submeshes): + raise NotImplementedError(f"{mesh.name}: {len(mesh.submeshes)} submeshes, {len(mats)} materials") + prims = [] + for s, tri in enumerate(mesh.submeshes): + if tri is None or not len(tri): + continue + mid = ref_id(mats[s]) + if mid is None: # empty slot: no material to draw the submesh with + null_slots.append({"mesh": mesh.name, "path": path, "submesh": s}) + continue + prims.append((tri.astype(np.uint32), material_for(mid))) + if not prims: continue - if len(mats) < len(tris): - raise NotImplementedError(f"{mesh.m_Name}: {len(tris)} submeshes, {len(mats)} materials") - m = graph.world(tf_pid) - p = (np.hstack([v, np.ones((len(v), 1))]) @ m.T)[:, :3] + m = prefab.world(i) + p = (np.hstack([mesh.pos, np.ones((len(mesh.pos), 1))]) @ m.T)[:, :3] p[:, 2] *= -1.0 # Unity LH -> glTF RH - if len(vn): - nw = vn @ np.linalg.inv(m[:3, :3]).T + if mesh.nrm is not None: + nw = mesh.nrm @ np.linalg.inv(m[:3, :3]).T nw /= np.maximum(np.linalg.norm(nw, axis=1, keepdims=True), 1e-12) nw[:, 2] *= -1.0 else: nw = np.empty((0, 3)) - uv = uv0.copy() - uv[:, 1] = 1.0 - uv[:, 1] # Unity UV origin bottom-left -> glTF top-left - prims = submesh_primitives( - tris, - mats, - material_for, - lambda i: null_slots.append({"mesh": mesh.m_Name, "path": graph.path(tf_pid), "submesh": i}), - ) - if not prims: - continue - is_active = graph.active(tf_pid) - glb.add_mesh(mesh.m_Name, p, nw, uv, prims, active=is_active) - n_mesh += 1 - n_inactive += 0 if is_active else 1 - - out_path = Path(out_path) - out_path.parent.mkdir(parents=True, exist_ok=True) - glb.write(out_path, {"backgroundKey": bg_key}) + uv = mesh.uv if mesh.uv is not None else np.zeros((len(mesh.pos), 2), np.float32) + active = prefab.active(i) + glb.add_mesh(mesh.name, p, nw, uv, prims, active=active) + nodes.append({"path": path, "mesh": mesh_id}) + counts["meshes"] += 1 + counts["inactive"] += 0 if active else 1 + doc = { - "backgroundKey": bg_key, - "glb": out_path.name, - "meshCount": n_mesh, - "inactiveMeshes": n_inactive, - "skippedEmptyMeshes": skipped, + "backgroundKey": key, + "glb": "room.glb", + "meshCount": counts["meshes"], + "inactiveMeshes": counts["inactive"], "nullMaterialSubmeshes": null_slots, "nullMeshFilters": null_meshes, - "meshesWithoutTriangles": no_triangles, - "materials": [ - { - "name": m["name"], - "shader": m["extras"]["unityShader"], - "alphaMode": m.get("alphaMode", "OPAQUE"), - "alphaCutoff": m.get("alphaCutoff"), - "doubleSided": m.get("doubleSided", False), - "unlit": "KHR_materials_unlit" in m.get("extensions", {}), - "renderState": m["extras"]["unityRenderState"], - } - for m in glb.materials - ], - "textures": [t["name"] for t in glb.textures], - "samplers": glb.samplers, + "emptyMeshes": empty, + "nodes": nodes, + "materialIds": list(materials), + **glb.summary(), } - write_json(out_path.with_suffix(".json"), doc) - return doc + return glb.bytes({"backgroundKey": key}), doc diff --git a/src/nnnotes/content/score.py b/src/nnnotes/content/score.py index 8c4cdfe..7c1ef92 100644 --- a/src/nnnotes/content/score.py +++ b/src/nnnotes/content/score.py @@ -33,7 +33,7 @@ from ..core.jsonio import write_json from ..formats import cri from ..formats.encoders import FLAC_LEVEL -from ..source import languages +from .live import DIFFICULTIES, MUSIC_RECORD_TABLES, chart_key, music_record f32 = np.float32 @@ -41,7 +41,6 @@ TICKS_PER_BEAT = 480 # 60000 / (bpm * 480) in SsTickConverter.TickToTimeMs TICKS_PER_WHOLE = 1920 # ticks per measure = numerator * 1920 / denominator MAX_LINES = 20 # LineIndexAssigner(0x14) -DIFFICULTIES = ("easy", "normal", "hard", "expert") # _easyID.._expertID -> file suffix _00.._03 # SsNoteType / SsFlickDir / SsEase / SsFadeType (SsRootDeserializer.Parse*) NOTE_TYPES = {"tap": 0, "flick": 1, "trace": 2, "long": 3, "guide": 4, "node": 5} @@ -1209,11 +1208,6 @@ def master_table(master_dir: Path, name: str) -> list: return json.loads((Path(master_dir) / f"{name}.json").read_text(encoding="utf-8"))["_allData"] -def chart_key(file_name: str) -> str: - """LiveEntry loads `Live/MusicScore/` + MasterLiveMusicScore._musicScoreTextFileName.""" - return f"Live/MusicScore/{file_name}" - - def fetch_chart(cat, file_name: str) -> bytes: """Raw TextAsset bytes of a chart (still gzip, as shipped).""" from ..unity.reader import load_closure @@ -1235,36 +1229,8 @@ def fetch_chart(cat, file_name: str) -> bytes: def music_rows(master_dir: Path, music_id: int) -> dict: - """MasterLiveMusic row, its 4 MasterLiveMusicScore rows, title / band name texts in every language - (languages.LANGUAGES) and sound rows.""" - music = next(r for r in master_table(master_dir, "MasterLiveMusic") if r["_id"] == music_id) - scores = {r["_id"]: r for r in master_table(master_dir, "MasterLiveMusicScore")} - text = {r["_id"]: r for r in master_table(master_dir, "MasterText")} - bands = {r["_id"]: r for r in master_table(master_dir, "MasterBand")} - sounds = {r["_id"]: r for r in master_table(master_dir, "MasterSound")} - sheets = {r["_id"]: r for r in master_table(master_dir, "MasterSoundCueSheet")} - - def tx(i): - r = text.get(i) - return None if r is None else {"id": i, **languages.texts(r)} - - def snd(i): - r = sounds.get(i) - if r is None: - return None - return {"MasterSound": r, "MasterSoundCueSheet": sheets.get(r["_soundCueSheetID"])} - - return { - "MasterLiveMusic": music, - "MasterLiveMusicScore": {d: scores.get(music[f"_{d}ID"]) for d in DIFFICULTIES}, - "title": tx(music["_titleTextID"]), - "bands": [ - {"MasterBand": bands.get(b), "name": tx(bands[b]["_nameTextID"]) if b in bands else None} - for b in music["_bandIDs"] - ], - "music": snd(music["_musicSoundID"]), - "jingle": snd(music["_jingleSoundID"]), - } + """live.music_record of the master tables in `master_dir`.""" + return music_record({n: master_table(master_dir, n) for n in MUSIC_RECORD_TABLES}, music_id) def extract_audio( diff --git a/src/nnnotes/content/sitefacts.py b/src/nnnotes/content/sitefacts.py new file mode 100644 index 0000000..388c10c --- /dev/null +++ b/src/nnnotes/content/sitefacts.py @@ -0,0 +1,187 @@ +"""Localized listing facts from master tables and product documents.""" + +from pathlib import Path + +from ..source import languages, master +from .advcommand import name as command_name +from .live2d import LIVE2D_PREFIX + +COSTUME_TABLE = "MasterCharacterCostume" + + +def optional_model_names(master_dir, language): + if master_dir is None: + return {} + try: + return model_names(master_dir, language) + except FileNotFoundError: + return {} + + +def all_pairs(master_dir): + from .score import DIFFICULTIES + + scores = {r["_id"] for r in master.table(master_dir, "MasterLiveMusicScore")} + return [ + (m["_id"], d) + for m in sorted(master.table(master_dir, "MasterLiveMusic"), key=lambda r: r["_id"]) + for d in DIFFICULTIES + if m.get(f"_{d}ID") in scores + ] + + +def chart(live, music, language): + rows, difficulty = live["master"], live["difficulty"] + row, score = rows["MasterLiveMusic"], rows["MasterLiveMusicScore"][difficulty] + title = rows.get("title") or {} + bands = [b.get("name") or {} for b in rows.get("bands") or []] + layer = music["sounds"][str(music["music"]["soundId"])]["layers"][0] + return { + "musicId": live["musicId"], + "difficulty": difficulty, + "title": title.get(language), + "bands": [b.get(language) for b in bands], + "language": language, + "titles": {c: title[c] for c in languages.LANGUAGES if isinstance(title.get(c), str)}, + "bandNames": { + c: [b[c] for b in bands] for c in languages.LANGUAGES if all(isinstance(b.get(c), str) for b in bands) + }, + "bandIds": list(row.get("_bandIDs") or []), + "stageBand": live["band"]["band"], + "level": score["_musicScoreLevel"], + "displayLevel": score["_musicScoreDisplayLevel"], + "notes": live["judgementNoteCount"], + "fullComboCount": live["fullComboCount"], + "durationMs": layer["samples"] * 1000 // layer["sampleRate"], + "sortOrder": row.get("_sortOrder"), + } + + +def story(episode, groups, selected_languages, language, player_settings): + title, row = episode.get("title") or {}, episode["master"] + commands = {"TalkWindow", *(c["cmd"] for c in episode["commands"] if not c.get("IgnoreData"))} + for field in ["_initializeEpisodes", "_finalizeEpisodes"]: + commands.update(command_name(r["Command"]) for r in player_settings[field]) + return { + "advId": episode["advId"], + "asset": episode["asset"], + "sheetName": row.get("_sheetName", ""), + "playbackMode": row["_playbackMode"], + "titles": { + c: title[languages.column(c)[1:]] + for c in languages.LANGUAGES + if isinstance(title.get(languages.column(c)[1:]), str) and title[languages.column(c)[1:]] + }, + "groups": groups, + "commands": sorted(commands), + "commandCount": episode["commandCount"], + "language": language, + "languages": list(selected_languages), + } + + +def story_groups(master_dir: Path) -> dict[int, list[dict]]: + """{advId: [group]}: the rows of the story tables that name an episode (docs/story-data-format.md, story + facts), in table order (MasterStoryEpisode, MasterStoryFriendshipEpisode, MasterStoryHomeSpotTapTalkEpisode, + MasterStoryLiveResultEpisode, MasterHomeSpot), rows of a table by id. Names in every language with a text; + a table the master data lacks gives no groups.""" + md = Path(master_dir) + + def table(name: str) -> list[dict]: + try: + return sorted(master.table(md, name), key=lambda r: r["_id"]) + except FileNotFoundError: + return [] + + texts = {r["_id"]: r for r in table("MasterText")} + + def names(text_id) -> dict: + row = texts.get(text_id) + return {c: t for c, t in languages.texts(row).items() if isinstance(t, str) and t} if row else {} + + chars = {r["_id"]: r for r in table("MasterCharacter")} + + def character(cid: int) -> dict: + return {"id": cid, "names": names((chars.get(cid) or {}).get("_nameTextID"))} + + chapters = {r["_id"]: r for r in table("MasterStoryChapter")} + friendships = {r["_id"]: r for r in table("MasterCharacterFriendship")} + spots = {r["_id"]: r for r in table("MasterHomeSpot")} + + def spot(sid: int) -> dict: + return {"id": sid, "names": names((spots.get(sid) or {}).get("_nameTextId"))} + + out: dict[int, list[dict]] = {} + for r in table("MasterStoryEpisode"): + ch = chapters.get(r["_chapterId"]) or {} + g = { + "kind": "chapter", + "id": r["_id"], + "episodeNumber": r["_episodeNumber"], + "chapter": { + "id": r["_chapterId"], + "names": names(ch.get("_nameTextId")), + "bandId": ch.get("_bandId", 0), + "special": bool(ch.get("_isSpecialStory")), + }, + "characters": [character(r["_characterId"])] if r.get("_characterId") else [], + } + out.setdefault(r["_advId"], []).append(g) + for r in table("MasterStoryFriendshipEpisode"): + f = friendships.get(r["_characterFriendshipId"]) or {} + ids = [f[k] for k in ("_masterCharacterIdA", "_masterCharacterIdB") if f.get(k)] + out.setdefault(r["_advId"], []).append( + { + "kind": "friendship", + "id": r["_id"], + "episodeNumber": r["_episodeNumber"], + "characters": [character(c) for c in ids], + } + ) + for r in table("MasterStoryHomeSpotTapTalkEpisode"): + out.setdefault(r["_advId"], []).append( + { + "kind": "spotTalk", + "id": r["_id"], + "spot": spot(r["_spotId"]), + "characters": [character(r["_characterId"])] if r.get("_characterId") else [], + } + ) + for r in table("MasterStoryLiveResultEpisode"): + out.setdefault(r["_advId"], []).append( + {"kind": "liveResult", "id": r["_id"], "characters": [character(c) for c in r.get("_characterIds") or []]} + ) + for r in table("MasterHomeSpot"): + if r.get("_advId"): + out.setdefault(r["_advId"], []).append( + {"kind": "spot", "id": r["_id"], "spot": spot(r["_id"]), "characters": []} + ) + return out + + +def model_names(master_dir: Path, language: str | None = None) -> dict[str, dict]: + """{model key: {"character", "names", "label"}} of the models the master data maps to one character: every + MasterCharacterCostume row maps the key LIVE2D_PREFIX + `_live2dPath` to `_characterID` (the MasterCharacter id, + `character`); `names`: {language code: the MasterText of the character's `_nameTextID`}, the languages with a + text; `label`: the name in `language` (left out without `language` or a text in it). A key the rows map to two + characters, or a character without a name text, is left out.""" + chars_of: dict[str, set] = {} + for r in master.table(master_dir, COSTUME_TABLE): + if r.get("_live2dPath") and r.get("_characterID") is not None: + chars_of.setdefault(LIVE2D_PREFIX + r["_live2dPath"], set()).add(r["_characterID"]) + chars = {r["_id"]: r for r in master.table(master_dir, "MasterCharacter")} + texts = {r["_id"]: r for r in master.table(master_dir, "MasterText")} + out = {} + for key, ids in sorted(chars_of.items()): + if len(ids) != 1: + continue + (cid,) = ids + row = texts.get((chars.get(cid) or {}).get("_nameTextID")) + names = {c: t for c, t in languages.texts(row).items() if isinstance(t, str) and t} if row else {} + if not names: + continue + entry = {"character": cid, "names": names} + if language in names: + entry["label"] = names[language] + out[key] = entry + return out diff --git a/src/nnnotes/content/spot.py b/src/nnnotes/content/spot.py index 6c05f16..4a34a6a 100644 --- a/src/nnnotes/content/spot.py +++ b/src/nnnotes/content/spot.py @@ -1,46 +1,79 @@ -"""Spot (explorable 2.5D scene) extractor -> spot.json + Spine assets. - -A spot = background prefab (cardboard room, see room.py) + situation prefab: -SpotSituationSettings (camera/interaction parameters), SpotCharacterController, -SpotCharacter x N (tap targets with focus transforms) and SpotSpineCharacter x M, -each driving a Spine.Unity SkeletonAnimation. Components are identified by -their MonoScript class (Catalog must be opened with `apk=`). - -A reference the prefab leaves empty is written as null, not filled in: a -SpotSpineCharacter with no `_animation` has null `skeletonData`, `animation` -and `world`; a SpotCharacter with no `_focus` has a null `focusWorld`. - -Spine files are written from the SkeletonDataAsset reference chain (write_skeleton): - SkeletonDataAsset.skeletonJSON -> skeleton (.json or binary .skel) - SkeletonDataAsset.atlasAssets[i].atlasFile -> .atlas - SpineAtlasAsset.materials[j]._MainTex -> PNG named after atlas page j +"""A spot (an explorable 2.5D scene of the home) assembled from the objects its situation prefab reaches: spot.json and +the Spine files of its characters. + +A spot is a background prefab (a room, content.room) and a situation prefab: SpotSituationSettings (camera and +interaction parameters), SpotCharacterController, SpotCharacter x N (tap targets with focus transforms) and +SpotSpineCharacter x M, each driving a Spine.Unity SkeletonAnimation. `spot` reads the situation prefab's document +(content.prefabs) and the objects its components reach, never a bundle; a MonoBehaviour's class is its script's. The +spot's row of MasterHomeSpot, its name and its tap talks come from the master data (`master_record`). + +A reference the prefab leaves empty is written as null: a SpotSpineCharacter with no `_animation` has null +`skeletonData`, `animation` and `world`; a SpotCharacter with no `_focus` has a null `focusWorld`. Fields that name +objects are written as the prefab document writes them (references). World matrices are 4x4 column-major in Unity's +world space (left-handed) of the situation prefab, its root's own transform included. + +Spine files (spine/) of each SkeletonDataAsset of the table (spine_files): + SkeletonDataAsset.skeletonJSON -> the TextAsset's bytes: the skeleton (.json or binary .skel) + SkeletonDataAsset.atlasAssets[i].atlasFile -> .atlas, as stored + SpineAtlasAsset.materials[j]._MainTex -> the Texture2D's PNG, named after atlas page j """ from __future__ import annotations -import io -import json -from pathlib import Path +import re -import UnityPy +from .prefabs import Objects, Prefab, mat4, ref_id, root_game_object, script_class -from ..core.jsonio import write_json -from ..source.catalog import Catalog -from ..source.config import apk_missing -from ..unity.reader import SceneGraph, script_class, strip_pptrs, texture_image +__all__ = ["LANGS", "SITUATION_KEY", "atlas_pages", "master_record", "situation_name", "spine_files", "spot"] +# a spot situation's catalog key: Spot/// +SITUATION_KEY = r"Spot/(?P[^/]+)/(?P[^/]+)/(?P=situation)" LANGS = ("_japanese", "_english", "_traditionalChinese", "_simplifiedChinese", "_korean") +MASTER_TABLES = ("MasterHomeSpot", "MasterText", "MasterStoryHomeSpotTapTalkEpisode") +HEADER = ("m_GameObject", "m_Script", "m_Enabled", "m_Name") # a component's Unity header fields +ANIMATION_FIELDS = ( + "_animationName", + "loop", + "timeScale", + "initialSkinName", + "initialFlipX", + "initialFlipY", + "pmaVertexColors", + "tintBlack", + "zSpacing", +) +MATRIX_CONVENTION = "Unity world space (left-handed), 4x4 column-major" + + +def situation_name(key: str) -> str: + """The of a situation key (ValueError for another key, a background's included).""" + m = re.fullmatch(SITUATION_KEY, key) + if m is None or m["situation"] == "Background": + raise ValueError(f"{key}: not a spot situation key (Spot///)") + return m["situation"] + + +def master_record(tables: dict[str, list[dict]], key: str) -> dict: + """{row, name, tapTalk} of the spot whose situation is `key`, from the master tables MASTER_TABLES ({table: + rows}): its MasterHomeSpot row, its name per language and {character id: adv ids} of its tap talks (ValueError + when no row, or several, has the key).""" + rows = [r for r in tables["MasterHomeSpot"] if r.get("_situationAssetPath") == key] + if len(rows) != 1: + raise ValueError(f"{key}: {len(rows)} MasterHomeSpot rows have it as their situation") + row = rows[0] + text = next((t for t in tables["MasterText"] if t.get("_id") == row.get("_nameTextId")), {}) + taps: dict[int, list[int]] = {} + for r in tables["MasterStoryHomeSpotTapTalkEpisode"]: + if r.get("_spotId") == row["_id"]: + taps.setdefault(r["_characterId"], []).append(r["_advId"]) + return { + "row": row, + "name": {lang[1:]: text.get(lang, "") for lang in LANGS}, + "tapTalk": {str(k): v for k, v in sorted(taps.items())}, + } -def _master(md: Path, name: str) -> list[dict]: - return json.loads((md / f"{name}.json").read_text(encoding="utf-8"))["_allData"] - - -def _mat4(m) -> list[float]: - return [float(x) for x in m.T.reshape(-1)] # column-major - - -def _atlas_pages(text: str) -> list[str]: +def atlas_pages(text: str) -> list[str]: """Page file names of a Spine atlas (a page line is followed by 'size:').""" lines = [ln.strip() for ln in text.splitlines()] return [ @@ -50,177 +83,129 @@ def _atlas_pages(text: str) -> list[str]: ] -def _text_bytes(ta) -> bytes: - s = ta.m_Script - return s.encode("utf-8", "surrogateescape") if isinstance(s, str) else bytes(s) +def _object(objects: Objects, v, what: str) -> str: + oid = ref_id(v) + if oid is None or oid not in objects: + raise ValueError(f"{what} is not in the object table") + return oid -def _target(ref): - """The object a PPtr refers to, None for an empty reference (path id 0).""" - return ref.read() if ref.path_id else None +def _text_asset(objects: Objects, v, what: str) -> tuple[str, bytes]: + """(name, bytes) of the TextAsset a reference names.""" + oid = _object(objects, v, what) + return objects.json(oid)["m_Name"], objects.bytes(oid, "data") -def write_skeleton(o, write, written_atlases: dict[int, str] | None = None) -> dict: - """The Spine files of the SkeletonDataAsset object `o` (module docstring), each given to `write(file name, - bytes)` in this order: the skeleton, then for each of its atlases not in `written_atlases` ({SpineAtlasAsset - path id: atlas file name}, updated here) the atlas text as stored and one PNG per page. Returns the skeleton's - record of spot.json: name, skeleton file, atlas files, scale, default mix, mixes.""" - written = {} if written_atlases is None else written_atlases - sda = o.read_typetree() - sk = o.read().skeletonJSON.read() - raw = _text_bytes(sk) - is_json = raw.lstrip()[:1] == b"{" - ext = ".json" if is_json else ".skel" - sk_file = sk.m_Name if sk.m_Name.endswith(ext) else f"{sk.m_Name}{ext}" # as the atlas below - write(sk_file, raw) +def spine_files(objects: Objects, sda: str, written: dict[str, str]) -> tuple[dict, dict[str, bytes]]: + """(the skeleton's record of spot.json: name, skeleton, atlases, scale, defaultMix, mixes; {file name: bytes}) + of the SkeletonDataAsset `sda`: the skeleton, then for each of its atlases not in `written` ({SpineAtlasAsset + object id: atlas file name}, updated here) the atlas text as stored and one PNG per page.""" + d = objects.json(sda) + name = d["m_Name"] + files: dict[str, bytes] = {} + sk_name, raw = _text_asset(objects, d.get("skeletonJSON"), f"{name}: the skeleton") + ext = ".json" if raw.lstrip()[:1] == b"{" else ".skel" + sk_file = sk_name if sk_name.endswith(ext) else f"{sk_name}{ext}" + files[sk_file] = raw atlases = [] - for ap in o.read().atlasAssets: - atlas_asset = ap.read() - if ap.path_id not in written: - af = atlas_asset.atlasFile.read() - araw = _text_bytes(af) - atext = araw.decode("utf-8") - a_file = af.m_Name if af.m_Name.endswith(".atlas") else f"{af.m_Name}.atlas" - write(a_file, araw) # the atlas text exactly as the game stores it - pages = _atlas_pages(atext) - mats = list(atlas_asset.materials) + for ap in d.get("atlasAssets") or (): + aid = _object(objects, ap, f"{name}: an atlas asset") + if aid not in written: + asset = objects.json(aid) + a_name, a_raw = _text_asset(objects, asset.get("atlasFile"), f"{name}: an atlas file") + a_file = a_name if a_name.endswith(".atlas") else f"{a_name}.atlas" + files[a_file] = a_raw # the atlas text exactly as the game stores it + pages = atlas_pages(a_raw.decode("utf-8")) + mats = asset.get("materials") or [] if len(mats) != len(pages): - raise RuntimeError(f"{a_file}: {len(pages)} pages vs {len(mats)} materials") + raise ValueError(f"{a_file}: {len(pages)} pages vs {len(mats)} materials") for page, mp in zip(pages, mats): - mat = mp.read() - tex = next(e.m_Texture.read() for k, e in mat.m_SavedProperties.m_TexEnvs if k == "_MainTex") - buf = io.BytesIO() - texture_image(tex).save(buf, format="PNG") - write(page, buf.getvalue()) - written[ap.path_id] = a_file - atlases.append(written[ap.path_id]) - return { - "name": sda["m_Name"], + mat = objects.json(_object(objects, mp, f"{a_file}: the material of page {page}")) + slot = (mat.get("textures") or {}).get("_MainTex") + files[page] = objects.bytes(_object(objects, slot and slot["texture"], f"{page}: the texture"), "image") + written[aid] = a_file + atlases.append(written[aid]) + record = { + "name": name, "skeleton": sk_file, "atlases": atlases, - "scale": sda["scale"], - "defaultMix": sda["defaultMix"], + "scale": d["scale"], + "defaultMix": d["defaultMix"], "mixes": [ - {"from": f, "to": t, "duration": d} - for f, t, d in zip(sda.get("fromAnimation", []), sda.get("toAnimation", []), sda.get("duration", [])) + {"from": f, "to": t, "duration": x} + for f, t, x in zip(d.get("fromAnimation", []), d.get("toAnimation", []), d.get("duration", [])) ], } - - -def spine_characters(objs: list, graph, skeletons: dict, world_of) -> list[dict]: - """SpotSpineCharacter components: object path, the SkeletonAnimation they drive (its skeleton data name, - initial animation settings and world matrix). `skeletons`: SkeletonDataAsset path id -> skeleton record. One - whose `_animation` is empty has those three null.""" - out = [] - for o in objs: - ssc = o.read_typetree() - path = graph.path(graph.tf_of_go[ssc["m_GameObject"]["m_PathID"]]) - anim_o = _target(o.read()._animation) - if anim_o is None: - out.append({"path": path, "skeletonData": None, "animation": None, "world": None}) + return record, files + + +def _fields(f: dict, *drop: str) -> dict: + return {k: v for k, v in f.items() if k not in HEADER and k not in drop} + + +def _first(prefab: Prefab, cls: str, key: str) -> dict: + found = [c for _, c in prefab.components(cls)] + if not found: + raise ValueError(f"{key}: no {cls} in the situation prefab") + return found[0]["fields"] + + +def spot(objects: Objects, key: str, master: dict) -> tuple[dict, dict[str, bytes]]: + """(spot.json, {spine/: bytes}) of the spot whose situation is `key` and whose master_record is `master` + (module documentation).""" + prefab = Prefab(objects.json(root_game_object(objects, key))) + files: dict[str, bytes] = {} + skeletons: dict[str, dict] = {} # SkeletonDataAsset object id -> its record + written: dict[str, str] = {} + sdas = [o for o in objects.ids("MonoBehaviour") if script_class(objects, o) == "SkeletonDataAsset"] + for sda in sorted(sdas, key=lambda o: (objects.json(o)["m_Name"], o)): + record, made = spine_files(objects, sda, written) + skeletons[sda] = record + for f, data in made.items(): + if f"spine/{f}" in files: + raise ValueError(f"{key}: two Spine files are named {f!r}") + files[f"spine/{f}"] = data + + spine_characters = [] + for i, c in prefab.components("SpotSpineCharacter"): + path = prefab.nodes[i]["path"] + anim = prefab.component_of(c["fields"].get("_animation")) + if anim is None: + spine_characters.append({"path": path, "skeletonData": None, "animation": None, "world": None}) continue - at = anim_o.object_reader.read_typetree() - out.append( + j, a = anim + sda = _object(objects, a["fields"].get("skeletonDataAsset"), f"{path}: the skeleton data") + spine_characters.append( { "path": path, - "skeletonData": skeletons[anim_o.skeletonDataAsset.path_id]["name"], - "animation": { - k: at[k] - for k in ( - "_animationName", - "loop", - "timeScale", - "initialSkinName", - "initialFlipX", - "initialFlipY", - "pmaVertexColors", - "tintBlack", - "zSpacing", - ) - }, - "world": world_of(at["m_GameObject"]), + "skeletonData": skeletons[sda]["name"], + "animation": {k: a["fields"][k] for k in ANIMATION_FIELDS}, + "world": mat4(prefab.world(j)), } ) - return out - - -def tap_targets(objs: list, graph, world_of) -> list[dict]: - """SpotCharacter components (tap targets) by character id: object path, their fields, world matrix and the world - matrix of their `_focus` transform (null when the reference is empty).""" - out = [] - for o in objs: - sc = o.read_typetree() - focus_o = _target(o.read()._focus) - out.append( + characters = [] + for i, c in prefab.components("SpotCharacter"): + focus = prefab.node_of(c["fields"].get("_focus")) + characters.append( { - "path": graph.path(graph.tf_of_go[sc["m_GameObject"]["m_PathID"]]), - **strip_pptrs({k: v for k, v in sc.items() if k != "_focus"}), - "world": world_of(sc["m_GameObject"]), - "focusWorld": None - if focus_o is None - else world_of(focus_o.object_reader.read_typetree()["m_GameObject"]), + "path": prefab.nodes[i]["path"], + **_fields(c["fields"], "_focus"), + "world": mat4(prefab.world(i)), + "focusWorld": None if focus is None else mat4(prefab.world(focus)), } ) - out.sort(key=lambda c: c["_characterId"]) - return out - - -def extract(cat: Catalog, md: Path, spot_id: int, out_dir: Path) -> dict: - if cat.apk is None: - raise apk_missing("resolving the Spot / Spine component classes") - spots = {r["_id"]: r for r in _master(md, "MasterHomeSpot")} - sp = spots[spot_id] - txt = {r["_id"]: r for r in _master(md, "MasterText")} - taps: dict[int, list[int]] = {} - for r in _master(md, "MasterStoryHomeSpotTapTalkEpisode"): - if r.get("_spotId") == spot_id: - taps.setdefault(r["_characterId"], []).append(r["_advId"]) - - out_dir = Path(out_dir) - spine_dir = out_dir / "spine" - spine_dir.mkdir(parents=True, exist_ok=True) - - env = UnityPy.load(*[str(p) for p in cat.fetch_key(sp["_situationAssetPath"])]) - graph = SceneGraph(env) - by_cls: dict[str | None, list] = {} - for o in env.objects: - if o.type.name == "MonoBehaviour": - by_cls.setdefault(script_class(o), []).append(o) - - def world_of(go_pptr) -> list[float]: - return _mat4(graph.world_of_go(go_pptr["m_PathID"])) - - settings = strip_pptrs(by_cls["SpotSituationSettings"][0].read_typetree()) - controller_o = by_cls["SpotCharacterController"][0] - controller = strip_pptrs(controller_o.read_typetree()) - - # Spine skeleton data -> files - def write(name: str, data: bytes) -> None: - (spine_dir / name).write_bytes(data) - - skeleton_files: dict[int, dict] = {} - written_atlases: dict[int, str] = {} - for o in by_cls.get("SkeletonDataAsset", []): - skeleton_files[o.path_id] = write_skeleton(o, write, written_atlases) - - spine_chars = spine_characters(by_cls.get("SpotSpineCharacter", []), graph, skeleton_files, world_of) - characters = tap_targets(by_cls.get("SpotCharacter", []), graph, world_of) - + characters.sort(key=lambda x: x["_characterId"]) + row = master["row"] doc = { - "spotId": spot_id, - "master": sp, - "name": {lang[1:]: txt.get(sp.get("_nameTextId"), {}).get(lang, "") for lang in LANGS}, - "tapTalk": {str(k): v for k, v in sorted(taps.items())}, - "situationSettings": settings, - "controller": {k: v for k, v in controller.items() if k not in ("_spotSpineCharacters", "_characters")}, + "spotId": row["_id"], + "master": row, + "name": master["name"], + "tapTalk": master["tapTalk"], + "situationSettings": _fields(_first(prefab, "SpotSituationSettings", key)), + "controller": _fields(_first(prefab, "SpotCharacterController", key), "_spotSpineCharacters", "_characters"), "characters": characters, - "spineCharacters": spine_chars, - "skeletons": list(skeleton_files.values()), - "resources": { - "background": [b.name for b in cat.resolve(sp["_backgroundAssetPath"])], - "situation": [b.name for b in cat.resolve(sp["_situationAssetPath"])], - }, - "matrixConvention": "Unity world space (left-handed), 4x4 column-major", + "spineCharacters": spine_characters, + "skeletons": list(skeletons.values()), + "matrixConvention": MATRIX_CONVENTION, } - write_json(out_dir / "spot.json", doc) - return doc + return doc, files diff --git a/src/nnnotes/content/story.py b/src/nnnotes/content/story.py deleted file mode 100644 index 83274ba..0000000 --- a/src/nnnotes/content/story.py +++ /dev/null @@ -1,165 +0,0 @@ -"""One ADV episode -> a self-contained story directory that a player can load without game code. - - /episode.json command list + text/sound/video shards + resource closure (adv.py) - /audio// every cue sheet in the episode's -SoundCueSheet - shard, decoded per cue + cues.json (cri.py) - /scene.json, textures/, shaders/ - player graphics, cameras, ADV fields, volumes, - settings and stages (advscene.py); textures/ and - shaders/ also hold those of the kind files below - /frames.json, effects.json, posteffects.json, stills.json, talkwindows.json, chat.json - the episode's frames, particle effects, post-effect - profiles, stills, talk windows and chat assets - (advmedia.py; each written only when used) - /videos/ Movie / Clip videos as WebM + videos.json (advvideo.py) - /ui/ ADV front canvas UI: ui.json, packed textures, - UI shaders; also the rule transitions (advui.py) - /crilips/ crilips.json + crilips.bin: the CRI Lips analysis data read from the - APK (crilips.py), when a voice reaches the CRI Lips path (needs_cri_lips) - /story.json index of the above (a kind file the episode does not use is null) - -The Live2D models the episode uses come from a model source (webmodel: ModelDir, SiteModels), `ensure(address) -> -{"id", "motionSync"}`: story.json `models` maps each model's address to its id, plus the source's `story_fields` -(ModelDir: `modelsDir`, the directory that holds /). Every resource kind of the closure has an exporter -(KIND_OUTPUT); the build stops before writing anything when a resource is missing from the catalog or of a kind -without one. -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from ..core.jsonio import write_json -from ..formats import cri, crilips -from ..source.catalog import Catalog -from ..unity.player import PlayerData -from . import adv, advmedia, advscene, advui, advvideo - -# resource kind -> where the story directory holds it -KIND_OUTPUT = { - "live2d": "story.json models (the model source)", - "stage": "scene.json stages", - "transition": "ui/ui.json transitions", - "video": advvideo.INDEX, - **{k: advmedia.FILES[k][1] for k in advmedia.FILES}, - **{k: advmedia.CHAT[1] for k in advmedia.CHAT_KINDS}, -} - - -CRILIPS_DIR = "crilips" -AIR_LIP_SYNC = ("airlipsync", "airlipsync_holdopen") # Talk Parameter1 modes without a voice analysis - - -def needs_cri_lips(episode: dict, split_key: str, motion_sync: list[bool]) -> bool: - """A voice of the episode reaches the CRI Lips analysis: a lip-synced voice row (Talk or Voice with TargetName and - VoiceIDs; IgnoreLipSync and IgnoreData not set) when a model of the episode has no MotionSync controller - (`motion_sync`: per model, model.json `motionSync`), or a Talk row whose voice also drives other speakers, which - use the analysis whatever their model (StartSharedAnalyzerLipSync: the EveryoneLipSync mode, or more speakers than - voices).""" - for c in episode["commands"]: - if c["cmd"] not in ("Talk", "Voice") or c.get("IgnoreData") or c.get("IgnoreLipSync"): - continue - if not c.get("TargetName") or not c.get("VoiceIDs"): - continue - if not all(motion_sync): - return True - if c["cmd"] == "Talk": - mode = (c.get("Parameter1") or "").strip().lower() - if mode == "everyonelipsync": - return True - if mode not in AIR_LIP_SYNC and len(c["TargetName"].split(split_key)) > len(c["VoiceIDs"]): - return True - return False - - -def check_kinds(resources: list[dict]) -> None: - """Raise for the first resource missing from the catalog or of a kind the story export does not handle.""" - missing = [r["address"] for r in resources if not r["present"]] - if missing: - raise RuntimeError(f"resources not in catalog: {missing}") - for r in resources: - if r["kind"] not in KIND_OUTPUT: - raise NotImplementedError(f"resource kind {r['kind']}: {r['address']}") - - -def build( - cat: Catalog, - master: Path, - player: PlayerData, - adv_id: int, - out_dir: Path, - audio_format: str = "flac", - audio: bool = True, - fonts: str = "open", - *, - models, - ui: bool = True, - language: str | None = None, - audio_options: dict | None = None, -) -> dict: - """Build the story directory with the models of the model source `models` (module docstring). `audio=False` - leaves the cue sheets undecoded (story.json `audio` is empty); `fonts`: "open" exports no font data of the game, - "game" exports it (advmedia, advui). `ui=False` writes no ui/ (story.json still names ui/ui.json: the caller - writes it, e.g. per language as storysite does); `language`: the UI's client language (a languages.LANGUAGES - code, needed with the UI); `audio_options`: further cri.decode options of every cue sheet - (flac_level, also). With audio, the CRI Lips data is read from the catalog's APK when a voice reaches it - (needs_cri_lips; story.json `crilips`, else null).""" - if ui and language is None: - raise ValueError("language: the client language of the UI is needed with ui=True") - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - episode = adv.to_json(adv.extract(cat, master, adv_id)) - check_kinds(episode["resources"]) - write_json(out_dir / "episode.json", episode) - - used = {r["address"]: models.ensure(r["address"]) for r in episode["resources"] if r["kind"] == "live2d"} - - sheets = {} - if audio: - for sheet in sorted({c["_cueSheetName"] for c in episode["cuesheets"].values()}): - cri.decode( - cat, sheet, out_dir / "audio" / sheet, fmt=audio_format, categories=True, **(audio_options or {}) - ) - sheets[sheet] = f"audio/{sheet}" - - media = advmedia.extract(cat, player, master, episode, out_dir, fonts=fonts) - scene = advscene.extract(cat, player, episode, out_dir, shaders=media["shaders"]) - ui_summary = ( - advui.extract(cat, player, episode, out_dir, fonts=fonts, language=language, master=master) - if ui and language is not None - else None - ) - videos = advvideo.extract(cat, episode, out_dir) - lips = None - if audio: - split = json.loads((out_dir / "scene.json").read_text(encoding="utf-8"))["settings"]["playerSettings"][ - "_targetNameSplitKey" - ] - if needs_cri_lips(episode, split, [m["motionSync"] for m in used.values()]): - if cat.apk is None: - raise crilips.CriLipsError("the CRI Lips data is read from the APK: set [paths] apk") - crilips.write_data(cat.apk, out_dir / CRILIPS_DIR) - lips = {"descriptor": f"{CRILIPS_DIR}/crilips.json", "data": f"{CRILIPS_DIR}/crilips.bin"} - index = { - "advId": adv_id, - "episode": "episode.json", - "scene": "scene.json", - "ui": "ui/ui.json", - "models": {a: m["id"] for a, m in used.items()}, - **models.story_fields(out_dir), - "audio": sheets, - **media["files"], - "videos": advvideo.INDEX if videos else None, - "crilips": lips, - } - write_json(out_dir / "story.json", index) - return { - **index, - "stages": scene["stages"], - "shaders": scene["count"], - "textures": scene["textures"], - "uiSummary": ui_summary, - "media": {"entries": media["counts"], "textures": media["textures"]}, - "videoCount": len(videos["videos"]) if videos else 0, - } diff --git a/src/nnnotes/content/storyasset.py b/src/nnnotes/content/storyasset.py new file mode 100644 index 0000000..ccf171f --- /dev/null +++ b/src/nnnotes/content/storyasset.py @@ -0,0 +1,45 @@ +"""The document of one story asset: a stage, frame, effect, post effect, still, chat window, chat icon, stamp or +transition, anything a story episode loads by catalog key. + +A story asset product holds the objects of one catalog key with the files a player reads: `asset.json` names the key's +objects, the clip bindings of the Animators of the table's prefabs (livenotes.animator_bindings) and the shader name +table (prefabs.shader_table). The product's manifest is `storyassets/.json`, where `` is manifest_name(key). +""" + +from __future__ import annotations + +import re + +from .livenotes import animator_bindings +from .prefabs import Objects, mesh_roles, shader_table + +__all__ = ["ASSET_FORMAT", "KEY", "asset", "manifest_name"] + +ASSET_FORMAT = 1 # asset.json "format" +KEY = re.compile(r"[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*") # the catalog keys a story asset product takes + + +def manifest_name(key: str) -> str: + """The manifest name of catalog key `key`: its "/" replaced by "~" (ValueError for a key KEY does not match).""" + if not KEY.fullmatch(key): + raise ValueError(f"{key!r}: not a story asset key") + return key.replace("/", "~") + + +def asset(objects: Objects, key: str) -> tuple[dict, set[str], dict[str, set[str]]]: + """(asset.json, the object ids the player reads, {object id: the roles of its files it reads}) of the story asset of + catalog key `key` (module documentation).""" + oids = sorted(o for o in objects.key(key) if o in objects) + if not oids: + raise ValueError(f"{key}: no objects of the key in the product's table") + shaders, roles = shader_table(objects) + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + doc = { + "format": ASSET_FORMAT, + "key": key, + "objects": oids, + "bindings": animator_bindings(objects), + "shaders": shaders, + } + return doc, keep, roles diff --git a/src/nnnotes/content/storychat.py b/src/nnnotes/content/storychat.py new file mode 100644 index 0000000..205e70d --- /dev/null +++ b/src/nnnotes/content/storychat.py @@ -0,0 +1,34 @@ +"""The chat product of a story: the rows of the shared chat shards every chat of a story reads (text, sounds and cue +sheets of chat messages). chat.json carries the rows, so the product keeps no objects. +""" + +from __future__ import annotations + +from .adv import parse_shard +from .prefabs import Objects +from .storyepisode import shard_text + +__all__ = ["CHAT_FORMAT", "SHARDS", "chat", "keys"] + +CHAT_FORMAT = 1 # chat.json "format" +SHARDS = { + "text": "Adv/Chat/AdvChat-Text", + "sounds": "Adv/Chat/AdvChat-Sound", + "cueSheets": "Adv/Chat/AdvChat-SoundCueSheet", +} + + +def keys() -> list[str]: + """The catalog keys of the chat shards, sorted.""" + return sorted(SHARDS.values()) + + +def chat(objects: Objects) -> tuple[dict, set[str], dict[str, set[str]]]: + """(chat.json, the object ids the player reads, {object id: the roles of its files it reads}) of the chat product. + Each shard's rows are [] when its key has no TextAsset; the product keeps no objects, so the set and the roles + are empty.""" + doc: dict = {"format": CHAT_FORMAT} + for name, key in SHARDS.items(): + text = shard_text(objects, key) + doc[name] = parse_shard(text)["rows"] if text else [] + return doc, set(), {} diff --git a/src/nnnotes/content/storyepisode.py b/src/nnnotes/content/storyepisode.py new file mode 100644 index 0000000..a2d6a8f --- /dev/null +++ b/src/nnnotes/content/storyepisode.py @@ -0,0 +1,195 @@ +"""Story episode (ADV) documents from a product's object table and master rows. + +The episode logic is `adv.build`. This module reads its inputs from a product: the episode's command list and shard +TextAssets from the object table (`Objects`), and the MasterAdv, MasterText and MasterAdvChat rows from master tables. +Every address is taken as present, since the product names the addresses it is asked for. + +`episode_json` gives episode.json; `story` gives story.json, which names the chats, the effect instances and the +products the story plays with (`parts`). +""" + +from __future__ import annotations + +from . import adv +from .adv import LANGS, PLAYER_SETTINGS_KEY, SHARD_SUFFIXES, AdvExtract, build +from .prefabs import Objects, has_json + +TABLES = ("MasterAdv", "MasterText", "MasterAdvChat") +STORY_FORMAT = 1 # story.json "format" +ASSET_KINDS = ( + "chaticon", + "chatstamp", + "chatwindow", + "effect", + "frame", + "posteffect", + "stage", + "still", + "timeline", + "transition", +) + + +def episode_key(asset: str) -> str: + """The catalog key of the episode whose `_advEpisodeAsset` is `asset`.""" + return f"Adv/Episode/{asset}/{asset}" + + +def keys(asset: str) -> list[str]: + """The catalog keys of an episode: its key, the four shard keys and the AdvPlayerSettings key, sorted.""" + base = episode_key(asset) + return sorted([base, *(f"{base}{suf}" for suf in SHARD_SUFFIXES), PLAYER_SETTINGS_KEY]) + + +def episode_record(tables: dict, adv_id: int) -> dict: + """The rows of episode `adv_id` from master tables {name: rows}: its MasterAdv row, its title's MasterText row (or + none), and every MasterAdvChat row sorted by id. KeyError when the MasterAdv row or the title text is missing.""" + rows = [r for r in tables["MasterAdv"] if r["_id"] == adv_id] + if not rows: + raise KeyError(f"MasterAdv has no row {adv_id}") + row = rows[0] + title_id = row["_titleTextId"] + titles = [r for r in tables["MasterText"] if r["_id"] == title_id] if title_id else [] + if title_id and not titles: + raise KeyError(f"MasterText has no row {title_id}") + return { + "MasterAdv": [row], + "MasterText": titles[:1], + "MasterAdvChat": sorted(tables["MasterAdvChat"], key=lambda r: r["_id"]), + } + + +def _single(key: str, what: str, hits: list[str]) -> str: + if len(hits) != 1: + raise ValueError(f"{key}: {len(hits)} {what}, expected one") + return hits[0] + + +def _collection(objects: Objects, key: str) -> list[dict]: + """The `Collection` of the episode's MonoBehaviour among the objects of `key`.""" + hits = [ + o + for o in objects.key(key) + if has_json(objects, o) and objects.info(o).get("class") == "MonoBehaviour" and "Collection" in objects.json(o) + ] + return objects.json(_single(key, "MonoBehaviours with a Collection", hits))["Collection"] + + +def shard_text(objects: Objects, key: str) -> str | None: + """The text of the TextAsset of `key` (its `data` file, UTF-8); None when the key has no TextAsset.""" + hits = [o for o in objects.key(key) if o in objects and objects.info(o).get("class") == "TextAsset"] + if not hits: + return None + return objects.bytes(_single(key, "TextAssets", hits), "data").decode("utf-8") + + +def _default_transition(objects: Objects) -> str: + """AdvPlayerSettings' default transition address.""" + hits = [ + o + for o in objects.key(PLAYER_SETTINGS_KEY) + if has_json(objects, o) and "_defaultTransitionAssetAddress" in objects.json(o) + ] + oid = _single(PLAYER_SETTINGS_KEY, "MonoBehaviours with a _defaultTransitionAssetAddress", hits) + return objects.json(oid)["_defaultTransitionAssetAddress"] + + +def _title(row: dict, texts: list[dict]) -> dict | None: + title_id = row["_titleTextId"] + if not title_id: + return None + hits = [r for r in texts if r["_id"] == title_id] + if not hits: + raise KeyError(f"MasterText has no row {title_id}") + return {lang[1:]: hits[0][lang] for lang in LANGS} + + +def _always(address: str) -> bool: + return True + + +def episode(objects: Objects, record: dict, *, has=_always) -> AdvExtract: + """The episode of `record` (episode_record) from the object table: pass the result to `adv.to_json`.""" + row = record["MasterAdv"][0] + base = episode_key(row["_advEpisodeAsset"]) + coll = _collection(objects, base) + shards = {suf: shard_text(objects, f"{base}{suf}") for suf in SHARD_SUFFIXES} + chats = {r["_id"]: r for r in record["MasterAdvChat"]} + + def chat(chat_id: int) -> dict: + if chat_id not in chats: + raise KeyError(f"MasterAdvChat has no row {chat_id}") + return chats[chat_id] + + return build( + row, + coll, + shards, + has, + chat, + lambda: _default_transition(objects), + _title(row, record["MasterText"]), + ) + + +def episode_json(x: AdvExtract) -> dict: + """`adv.to_json(x)` with each resource reduced to its kind and address. The product names the addresses it is + asked for, so a resource carries no presence flag.""" + doc = adv.to_json(x) + doc["resources"] = [{"kind": r["kind"], "address": r["address"]} for r in doc["resources"]] + return doc + + +def lip_synced(episode: dict) -> bool: + """A command of the episode is a voice with a target and voice cues, and neither IgnoreData nor IgnoreLipSync is + set. Such a voice can reach the CRI Lips analysis; whether it does depends on the MotionSync of its models.""" + return any( + c["cmd"] in ("Talk", "Voice") + and c.get("TargetName") + and c.get("VoiceIDs") + and not c.get("IgnoreData") + and not c.get("IgnoreLipSync") + for c in episode["commands"] + ) + + +def chat_ids(episode: dict) -> list[int]: + """The distinct non-zero TargetChatID values of the episode's commands, sorted.""" + return sorted({c["TargetChatID"] for c in episode["commands"] if c.get("TargetChatID")}) + + +def parts(episode: dict) -> dict: + """What the story plays with, from episode.json: the addresses of the ASSET_KINDS resources, the addresses of the + live2d models, the cue sheets of the episode, the addresses of its videos, whether it shows chat and whether a + voice reaches the CRI Lips analysis. Talk windows are not listed.""" + resources = episode["resources"] + return { + "assets": sorted(r["address"] for r in resources if r["kind"] in ASSET_KINDS), + "models": sorted(r["address"] for r in resources if r["kind"] == "live2d"), + "sounds": sorted({cs["_cueSheetName"] for cs in episode["cuesheets"].values() if cs.get("_cueSheetName")}), + "videos": sorted(r["address"] for r in resources if r["kind"] == "video"), + "chat": bool(chat_ids(episode)), + "crilips": lip_synced(episode), + } + + +def story(episode: dict, record: dict, paths: dict) -> dict: + """The story.json document: the MasterAdvChat row of each chat id of the episode (KeyError when there is none), the + effect instances (TargetName -> "Adv/Effect/" + TargetAssetName) and `parts`. `paths` are the manifest paths of the + products the story plays with, given by the caller: {"scene", "assets": {address: path}, "models": {address: path}, + "sounds": {cue sheet: path}, "videos": {address: path}, "chat", "crilips"}.""" + chats = {} + for chat_id in chat_ids(episode): + hits = [r for r in record["MasterAdvChat"] if r["_id"] == chat_id] + if not hits: + raise KeyError(f"MasterAdvChat has no row {chat_id}") + chats[str(chat_id)] = hits[0] + prefix = adv.RESOURCE_PREFIX["Effect"][1] + return { + "format": STORY_FORMAT, + "advId": episode["advId"], + "episode": "episode.json", + "chats": chats, + "effects": {name: prefix + asset for name, asset in adv.effect_instances(episode["commands"]).items()}, + "parts": paths, + } diff --git a/src/nnnotes/content/storyfontproduct.py b/src/nnnotes/content/storyfontproduct.py new file mode 100644 index 0000000..3ec9ea9 --- /dev/null +++ b/src/nnnotes/content/storyfontproduct.py @@ -0,0 +1,357 @@ +"""An episode's text and glyph pages assembled from object tables and explicit font bytes.""" + +from __future__ import annotations + +import copy + +from ..source import languages +from ..unity import fontsettings, textstyle, tmpfont +from ..unity.ids import object_id, parse_object_id +from ..unity.texels import MemoryPacker, _safe, packed_png +from . import advui, storyfonts, storysimpleui, storyui, tmpassets +from .prefabs import component_class, key_object, mono_json, ref_id, shader_table + +FORMAT = "nnnotes.story-fonts/1" + + +def asset_keys(episode: dict) -> list[str]: + """The episode's chat windows and frames whose texts contribute glyphs.""" + return sorted({r["address"] for r in episode["resources"] if r["kind"] in ("chatwindow", "frame")}) + + +def text_records(objects, keys: list[str], language: str, lang: dict, settings) -> tuple[dict, dict, dict]: + """(component-id text records, sprite bindings, frame nodes) for the named prefabs.""" + mode = languages.mode(language) + out, sprites, frames = {}, {}, {} + for key in sorted(set(keys)): + oid = key_object(objects, key, "GameObject") + file, _ = parse_object_id(oid) + nodes = objects.json(oid)["nodes"] + extra = key.startswith((storyfonts.FRAME_PREFIX, "Adv/Chat/Prefabs/")) + local_lang = advui._lookup_lang(lang) if extra else lang + styles = storyui._Styles(local_lang, mode, settings) + by_path = { + n["path"]: object_id(file, int(c["pathId"])) + for n in nodes + for c in n["components"] + if component_class(c) in storyui.TEXT_CLASSES + } + driven = set() + for n in nodes: + for c in n["components"]: + if component_class(c) in storyfonts.UI_TEXT_CLASSES: + target = c["fields"].get("_targetText") or {} + driven.add(ref_id(target) or by_path.get(target.get("gameObject"))) + frame_nodes = [] + for node in nodes: + comps = [{"class": component_class(c), **c["fields"]} for c in node["components"]] + frame_nodes.append({"path": node["path"], "components": comps}) + texts = [c for c in node["components"] if component_class(c) in storyui.TEXT_CLASSES] + locs = [c for c in comps if c["class"] == "LocalizeText"] + if not texts: + continue + if len(texts) != 1 or len(locs) > 1: + raise ValueError(f"{node['path']}: ambiguous text or localization components") + comp = {"class": component_class(texts[0]), **texts[0]["fields"]} + tid = object_id(file, int(texts[0]["pathId"])) + comp["m_fontAsset"] = {"name": storyui._name(objects, comp["m_fontAsset"])} + comp["m_sharedMaterial"] = {"material": storyui._name(objects, comp["m_sharedMaterial"])} + record = styles.record(node["path"], comp, locs[0] if locs else None) + record.update(storyfonts.text_extras(node["path"], comps)) + # Glyph selection always has an effective font, including texts without LocalizeText. + effective = record["localized"] or { + "fontAsset": comp["m_fontAsset"]["name"], + "material": comp["m_sharedMaterial"]["material"], + "lineSpacing": comp["m_lineSpacing"], + } + record["font"] = dict(effective) + if tid in driven: + sprites[tid] = {"m_tintAllSprites": comp["m_tintAllSprites"]} + elif ref_id(comp.get("m_spriteAsset")) is not None: + raise NotImplementedError(f"{node['path']}: a text with its own sprite asset") + out[tid] = record + if key.startswith(storyfonts.FRAME_PREFIX): + frames[key[len(storyfonts.FRAME_PREFIX) :]] = frame_nodes + return out, sprites, frames + + +def shown_texts( + episode: dict, records: dict, master: dict, settings: dict, status: dict, frames: dict, field: str +) -> list[str]: + """The strings an episode can display, including localized labels and dynamic chat/frame text.""" + texts = [row.get(field) for row in episode["text"].values()] + texts.append((episode.get("title") or {}).get(field)) + host_title = (episode.get("host") or {}).get("title") or {} + code = next((code for code, (_, column) in languages.LANGUAGES.items() if column[1:] == field), None) + texts.append(host_title.get(code)) + texts += [r["text"] for r in storyui.master_id_texts(settings, master).values()] + texts += list(status.values()) + advui.chat_runtime_texts(episode) + for r in records.values(): + style = r["style"] + if style.get("textKey"): + texts.append(master[str(style["textKey"])]) + else: + texts.append(style.get("text")) + texts += storyfonts.frame_texts(frames, episode, field) + return [s for s in texts if isinstance(s, str)] + + +def line_breaking(objects) -> dict: + tt = mono_json(objects, storyui.script_root(objects, "TMP_Settings")) + + def text(field): + oid = ref_id(tt[field]) + return objects.bytes(oid, "data").decode("utf-8") if oid else "" + + return { + "leading": text("m_leadingCharacters"), + "following": text("m_followingCharacters"), + "useModernHangulLineBreakingRules": bool(tt["m_UseModernHangulLineBreakingRules"]), + } + + +def sprite_record(objects, fs): + oid = key_object(objects, advui.EMOJI_SPRITE_ASSET_KEY, "TMP_EmojiSpriteAsset") + tt = objects.json(oid) + sheet = ref_id(tt["spriteSheet"]) + material = ref_id(tt["m_Material"]) + if sheet is None or material is None: + raise ValueError("emoji sprite asset needs a sheet and material") + return advui.sprite_record(tt, objects.json(sheet), fs.material(material)), sheet + + +def game_font_set(fs, bindings, shown, missing_char, sprite_drawn, *, subset: str) -> dict: + """Reduced game font assets, their materials, fallback chains and PNG page bytes.""" + primaries = sorted({b["localized"]["fontAsset"] for b in bindings}) + needed, runtime, reports, missing_glyphs = {}, {}, {}, {} + for name in primaries: + primary = fs.loaded(name) + chars = sorted({ord(c) for s in shown for c in s} - sprite_drawn.get(name, set())) + tmpfont.check_glyph_variants(fs, primary, shown) + coverage, baked, dynamic = tmpfont.glyph_coverage(fs, primary, chars) + mg = tmpfont.missing_glyph(fs, primary, chars, missing_char) + for u in [storyfonts.UNDERLINE_CHARACTER, *([mg["unicode"]] if mg else [])]: + hit = fs.lookup(primary, u) + if hit is not None: + kind, font = hit + if u == storyfonts.UNDERLINE_CHARACTER and font is not primary: + continue + if kind != "synthesized": + (baked if kind == "baked" else dynamic).setdefault(font.name, set()).add(u) + if mg: + missing_glyphs[name] = mg + reports[name] = coverage + for target, source in ((needed, baked), (runtime, dynamic)): + for fname, chars in source.items(): + target.setdefault(fname, set()).update(chars) + materials, text_materials = tmpfont.text_material_records( + fs, + [b["localized"] for b in bindings], + set(primaries), + needed, + runtime, + fs.named_material, + ) + packer = MemoryPacker() + fonts = tmpfont.export_fonts( + fs, + set(primaries), + needed, + runtime, + text_materials, + packer, + subset=subset, + ) + textures, pages = packer.build() + tmpfont.resolve_font_blocks(fonts) + for name, font in fonts.items(): + font["fallbacks"] = storyfonts.search_order( + lambda n: [f.name for f in fs.fallbacks(fs.loaded(n))], + name, + set(fonts), + ) + if name in missing_glyphs: + font["missingGlyph"] = missing_glyphs[name] + for b in bindings: + loc = b["localized"] + for fb in fonts[loc["fontAsset"]]["fallbacks"]: + name = f"{loc['material']} + {fb}" + materials[name] = storyfonts.fallback_material( + materials[loc["material"]], + materials[fonts[fb]["material"]], + name, + ) + return { + "fonts": fonts, + "textures": textures, + "materials": materials, + "pages": pages, + "missing": {ord(c) for r in reports.values() for c in r["missing"]}, + "substituted": {u for r in missing_glyphs.values() for u in r["characters"]}, + } + + +def build( + objects, episode: dict, language: str, master: dict, settings, source: str, font_files: dict +) -> tuple[dict, dict, set, dict]: + """(fonts.json, PNG files, shader ids, shader roles) from object data and font inputs {code: bytes}.""" + lang = textstyle.language_fonts_json(mono_json(objects, storyui.script_root(objects, "LocalizeManager"))) + ui_keys = [ + storyui.WIDGET_KEY, + storyui.CHAT_WIDGET_KEY, + storyui.UI_MANAGER_KEY, + *storyui.window_keys(objects.keys()).values(), + *storyui.DIALOG_KEYS.values(), + ] + if (episode.get("master") or {}).get("_playbackMode") == 1: + ui_keys += storysimpleui.keys(objects.keys()) + records, sprite_bindings, frames = text_records(objects, ui_keys + asset_keys(episode), language, lang, settings) + status = storyui.chat_status_texts(objects, master) + ids = objects.json(key_object(objects, storyui.MASTER_ID_SETTINGS_KEY, "AdvMasterIdSettings")) + shown = shown_texts(episode, records, master, ids, status, frames, languages.column(language)[1:]) + return font_product( + objects, + records, + sprite_bindings, + shown, + language, + lang, + settings, + source, + font_files, + format=FORMAT, + subset="episode text, synthesized control characters, underline and missing glyph", + ) + + +def font_product( + objects, + records: dict, + sprite_bindings: dict, + shown: list[str], + language: str, + lang: dict, + settings, + source: str, + font_files: dict, + *, + format: str, + subset: str, +) -> tuple[dict, dict, set, dict]: + """Font records and PNG pages for explicit component records and displayed strings.""" + chars = sorted({ord(c) for s in shown for c in s}) + bindings = {tid: {"localized": dict(r["font"])} for tid, r in records.items()} + fs = tmpassets.FontSet(objects) + for name in sorted({r["font"]["fontAsset"] for r in records.values()}): + fs.by_key(fontsettings.font_key(tmpassets._Keys(objects), name, name)) + material_ids = {fs.loaded(name).material_oid for name in fs.fonts} + for r in records.values(): + font = r["font"] + key = fontsettings.font_key(tmpassets._Keys(objects), font["fontAsset"], font["material"]) + material_ids.add(key_object(objects, key, "Material")) + rec, sheet, sprite_drawn = None, None, {} + if sprite_bindings: + rec, sheet = sprite_record(objects, fs) + sprite_id = key_object(objects, advui.EMOJI_SPRITE_ASSET_KEY, "TMP_EmojiSpriteAsset") + sprite_material = ref_id(objects.json(sprite_id)["m_Material"]) + if sprite_material is None: + raise ValueError("emoji sprite asset needs a material") + material_ids.add(sprite_material) + sprite_drawn = storyfonts.drawn_by_sprites( + rec, + [bindings[t] for t in sprite_bindings], + chars, + lambda name, u: fs.lookup(fs.loaded(name), u), + ) + missing_char = tmpassets.missing_glyph_character(objects) + if source == "open": + loaded = {} + + def font_of(code): + if code not in font_files: + return storyfonts.no_font_file(code) + if code not in loaded: + loaded[code] = storyfonts.FontFile(f"{code}.font", data=font_files[code]) + return loaded[code] + + result = storyfonts.build_open_font_set( + language, + font_of(language), + list(bindings.values()), + shown, + lang, + missing_char, + font_of=font_of, + sprite_drawn=sprite_drawn, + settings=settings, + ) + elif source == "game": + result = game_font_set(fs, list(bindings.values()), shown, missing_char, sprite_drawn, subset=subset) + else: + raise ValueError(f"font source {source!r}: expected open or game") + for tid, record in records.items(): + record["font"] = bindings[tid]["localized"] + coverage = {"characters": len(chars), "missing": [chr(u) for u in sorted(result["missing"])]} + if result["substituted"]: + coverage["missingGlyph"] = [chr(u) for u in sorted(result["substituted"])] + doc = { + "format": format, + "language": language, + "source": source, + **{k: result[k] for k in ("fonts", "textures", "materials")}, + "texts": records, + "sprites": {}, + "coverage": coverage, + "lineBreaking": line_breaking(objects), + } + pages = result["pages"] + indices = storyfonts.sprite_needs(rec, shown, set().union(*sprite_drawn.values())) if rec else [] + if rec is not None and indices: + if source == "open": + emoji = storyfonts.EmojiFont("emoji.font", data=font_files["emoji"]) if "emoji" in font_files else None + name = emoji.asset_name if emoji else storyfonts.NO_EMOJI_FONT + sprites = storyfonts.open_sprite_asset(emoji, rec, indices, name) + for pname, px in sprites["pages"]: + pages[sprites["textures"][pname]["texture"]] = packed_png(px) + asset, textures, material = sprites["asset"], sprites["textures"], sprites["material"] + coverage["sprites"] = {"characters": len(asset["characters"]), "missing": sprites["missing"]} + else: + name = rec["name"] + sh = rec["sheet"] + if sh["mipCount"] != 1: + raise NotImplementedError(f"{name}: sprite sheet with mip levels") + page, path = f"sprite_{name}", f"fonts/{_safe(name)}.png" + pages[path] = objects.bytes(sheet, "image") + material = storyfonts._sheet_material( + rec["material"], rec["material"]["material"], page, sh["width"], sh["height"] + ) + asset = {k: copy.deepcopy(rec[k]) for k in ("faceInfo", "characters", "glyphs", "sequences")} + asset["material"] = material["material"] + for g in asset["glyphs"].values(): + if g["rect"]["m_Width"] and g["rect"]["m_Height"]: + g["packed"] = {"texture": page, "dx": 0, "dy": 0} + textures = { + page: {"texture": path, **{k: sh[k] for k in ("name", "width", "height", "mipCount", "settings")}} + } + coverage["sprites"] = {"characters": len(asset["characters"]), "missing": []} + doc["textures"].update(textures) + doc["materials"][material["material"]] = material + doc["spriteAssets"], doc["emojiSpriteAsset"] = {name: asset}, name + doc["sprites"] = {t: {"spriteAsset": name, **v} for t, v in sprite_bindings.items()} + found = {ref_id(objects.json(oid)["shader"]) for oid in material_ids if oid is not None} + shaders, roles = shader_table(objects, found=[o for o in found if o is not None]) + keep, selected, keywords = set(), {}, {} + for name, material in sorted(doc["materials"].items()): + shader = material["shader"]["shader"] + sid = shaders[shader] + variants = objects.json(sid, "index")["variants"] + gles = [v for v in variants if v["platform"] == "gles3" and v["type"] == "GLES3"] + known = {k for v in gles for k in v["keywords"]} + want = sorted(k for k in material["keywords"] if k in known) + if not any(sorted(v["keywords"]) == want and v["stage"] == "vertex" for v in gles): + raise ValueError(f"{shader}: no GLES3 variant for material {name} keywords {want}") + keywords[name], selected[shader] = want, sid + keep.add(sid) + storyfonts._material_textures(material) + doc["materialKeywords"], doc["shaders"] = keywords, selected + return doc, pages, keep, {o: roles[o] for o in keep} diff --git a/src/nnnotes/content/storyfonts.py b/src/nnnotes/content/storyfonts.py index afcd601..3637038 100644 --- a/src/nnnotes/content/storyfonts.py +++ b/src/nnnotes/content/storyfonts.py @@ -1,48 +1,4 @@ -"""The TextMeshPro font data of a story's texts in one language -> /ui/fonts.json, ui/fonts/*.png and -ui/languages.json (the site's per-language files, storysite.py; format in ournotes-player docs/story-data-format.md). - - ui/fonts.json font assets reduced to the characters the episode shows in the language, their glyph pages - (ui/fonts/*.png), the text materials, their GLES3 keyword sets and the text binding of every - text node of ui/ui.json: its serialized TextMeshPro fields and the font asset, material and - line spacing it uses in the language (LocalizeText); with open fonts also `chatTexts` and - `dialogTexts`, the bindings of the chat window texts of ui.json `chatTexts` and of the text - nodes of its `dialogs` - ui/languages.json the language's LanguageMode, text field, line spacing and the font asset and line metrics of - each font role - ui/shaders/ the shader the text materials name (TextMeshPro/Mobile/Distance Field) is added to the UI's - -Two sources give the same format: - - open_fonts (default) glyphs generated from a font file per language: each text's localized game font asset - (LocalizeText) is replaced by an asset of the font file with the game asset's point size, padding, - style settings and render mode, holding the characters of the episode's text table, its title, the - static labels of the UI and the chat windows' status and run-time texts in that language plus - TextMeshPro's synthesized control characters and U+005F (UNDERLINE_CHARACTER). Face info and glyph - metrics come from the font file at that point size (FreeType, no hinting); the distance field is - tmpfont.RuntimeGlyphs' (the supersampled render modes rasterize at up to MAX_OVERSAMPLE times the size, - recorded in atlasRenderMode; the anti-aliased distance-field modes are generated as the 1X distance - field and recorded as SDF). Glyphs are shelf-packed, U+005F first (on the first page), then in code - point order of their first character into pages of PAGE_WIDTH texels, at most PAGE_MAX_HEIGHT high; - every page of an asset has the same size. The text materials are the game's localized materials with - the page as _MainTex and the page size as _TextureWidth / _TextureHeight. The game asset's fallback - chain is mirrored (open_font_set): each game fallback asset stands for an asset of the font file of - the language LocalizeManager lists it for, which holds the characters the assets before it in the - chain lack. The game assets' settings, character coverage, fallback lists and text materials come from - nnnotes' table of them (fontsettings.builtin()): no game font asset, material, glyph or font bundle is - read. - game_fonts the game's TMP font assets (advui.extract with fonts="game", tmpfont.export_fonts): the same records - moved from its ui.json into fonts.json, the glyph pages into ui/fonts/. - -Emoji: every text a Fwk.UI.UIText drives has LocalizeManager's emoji sprite asset (UIText.Awake), which draws the -characters its font assets lack and the tags the emoji search writes for emoji sequences. With open -fonts the sprite asset holds the sprites the episode's texts can draw (sprite_needs), each image drawn from the emoji -font (EmojiFont; none: empty glyphs) at the game sprite's size, and the open font assets of those texts leave out the -code points a sprite draws; with game fonts it is the game's asset with its sheet. Without a sprite to draw, fonts.json -has no sprite asset. - -Same inputs (font file, episode, game data) and library versions give the same bytes; generated glyph cells are kept -in the process cache (disk layer when the build configures one) by font file, sizes and glyph. -""" +"""Font file readers, glyph generation, atlas packing and material records for text products.""" from __future__ import annotations @@ -50,30 +6,22 @@ import hashlib import json import math -import shutil import unicodedata from pathlib import Path import numpy as np -from ..core import cache, jsonio -from ..formats import shader as shader_mod -from ..source import languages, master +from ..core import cache +from ..source import languages from ..source.config import ConfigError from ..unity import fontsettings, textstyle, tmpfont -from ..unity.export import Exporter, _safe, packed_png, texture_png +from ..unity.texels import _safe, packed_png from . import adv, advui -FONTS_FORMAT = "ournotes.story-fonts/1" -LANGUAGE_FORMAT = "ournotes.story-language/1" -FONTS_DOC = "fonts.json" -LANGUAGE_DOC = "languages.json" PAGES_DIR = "fonts" PAGE_WIDTH = 2048 PAGE_MAX_HEIGHT = 4096 MAX_OVERSAMPLE = 8 -SOURCES = ("open", "game") -TEXT_SHADER_PREFIX = "TextMeshPro/" # UnityEngine.TextCore.LowLevel.GlyphRasterModes flags of the supersampled render modes -> raster pixels per texel OVERSAMPLE_FLAGS = {tmpfont.RASTER_MODE["8X"]: 8, tmpfont.RASTER_MODE["16X"]: 16, tmpfont.RASTER_MODE["32X"]: 32} ELEMENT_CHARACTER = 1 # TMP TextElementType.Character @@ -85,40 +33,13 @@ UI_RUBY_FIELDS = ("_rubyMarginTop",) KOREAN_ADJUST_CLASS = "LocalizeKoreanAdjust" # the font style a text gets in Korean (LocalizeKoreanAdjust.Apply) UI_TEXT_CLASSES = ("UIText", "UIRubyText") # Fwk.UI.UIText and its subclass -GLYPHS = cache.bucket("storyglyphs", disk=True) -FACE_KEYS = ( - "m_FaceIndex", - "m_FamilyName", - "m_StyleName", - "m_PointSize", - "m_Scale", - "m_UnitsPerEM", - "m_LineHeight", - "m_AscentLine", - "m_CapLine", - "m_MeanLine", - "m_Baseline", - "m_DescentLine", - "m_SuperscriptOffset", - "m_SuperscriptSize", - "m_SubscriptOffset", - "m_SubscriptSize", - "m_UnderlineOffset", - "m_UnderlineThickness", - "m_StrikethroughOffset", - "m_StrikethroughThickness", - "m_TabWidth", -) +GLYPHS = cache.bucket("storyglyphs") STYLE_KEYS = fontsettings.STYLE_KEYS # OpenType name table ids NAME_FAMILY, NAME_STYLE, NAME_VERSION, NAME_LICENSE, NAME_LICENSE_URL = 1, 2, 5, 13, 14 NAME_TYPO_FAMILY, NAME_TYPO_STYLE = 16, 17 -def _dump(obj) -> bytes: - return jsonio.dumps(obj, ensure_ascii=False, indent=1, sort_keys=True).encode("utf-8") + b"\n" - - def _salt() -> str: return cache.key(cache.source_salt(__file__), cache.source_salt(tmpfont.__file__, "freetype-py", "scipy", "numpy")) @@ -127,11 +48,11 @@ def _salt() -> str: class FontFile: """A font file of one's own (OpenType / TrueType; the first face of a collection): its bytes, identity and names.""" - def __init__(self, path, face_index: int = 0): + def __init__(self, path, face_index: int = 0, *, data: bytes | None = None): from fontTools.ttLib import TTFont self.path = Path(path) - self.data = self.path.read_bytes() + self.data = self.path.read_bytes() if data is None else data self.face_index = face_index self.sha256 = hashlib.sha256(self.data).hexdigest() import io @@ -279,69 +200,6 @@ def pack_cells(cells: list[tuple[int, int, int]]) -> tuple[dict[int, tuple[int, # ---------------------------------------------------------------- texts -def _ui_prefab_nodes(ex: Exporter, ui_doc: dict) -> tuple[dict[str, dict], dict[str, dict]]: - """({path: node} of UIAdvWidget's prefab, {path: node} of the talk windows of ui.json under TalkView).""" - widget = {n["path"]: n for n in ex.prefab(advui.WIDGET_KEY)["nodes"]} - names = [rec["name"] for rec in ui_doc["nodes"] if rec["path"] == f"{advui.WINDOW_PARENT}/{rec['name']}"] - return widget, {n["path"]: n for n in advui.window_nodes(ex, names)} - - -def ui_text_targets(nodes, root: str = "") -> set[str]: - """The paths (`root` + the prefab path) of the texts the Fwk.UI.UIText components (and its subclass UIRubyText) of - the prefab `nodes` drive (_targetText, on any node): UIText.Awake registers OnEmojiSpriteAssetLoaded with - LocalizeManager, which sets the text's sprite asset (TMP_Text.spriteAsset) to LocalizeManager's emoji sprite - asset, over any serialized one (the asset is loaded at the title, before any story). A UIText without a target - text drives none.""" - out = set() - for n in nodes: - for c in n["components"]: - if c.get("class") in UI_TEXT_CLASSES: - target = (c.get("_targetText") or {}).get("gameObject") - if target is not None: - out.add(root + target) - return out - - -def story_ui_text_targets(ex: Exporter, ui_doc: dict) -> set[str]: - """ui_text_targets of UIAdvWidget and of the talk windows of ui.json (paths under TalkView).""" - names = [rec["name"] for rec in ui_doc["nodes"] if rec["path"] == f"{advui.WINDOW_PARENT}/{rec['name']}"] - out = ui_text_targets(ex.prefab(advui.WIDGET_KEY)["nodes"]) - for name in names: - out |= ui_text_targets(ex.prefab(advui.window_key(ex.cat, name))["nodes"], f"{advui.WINDOW_PARENT}/") - return out - - -def sprite_binding(path: str, node: list[dict], driven: bool, sprite: str | None) -> dict: - """The sprite settings of a text binding: for a text a UIText drives (`driven`), `spriteAsset` = `sprite` (the - emoji sprite asset's name in fonts.json) and the text's serialized `m_tintAllSprites`; {} for any other text, which - keeps its serialized sprite asset (only none is implemented).""" - comps = [c for c in node if c.get("class") in advui.TMP_CLASSES] - if len(comps) != 1: - raise RuntimeError(f"{path}: {len(comps)} text components") - c = comps[0] - if not driven: - if c.get("m_spriteAsset"): - raise NotImplementedError(f"{path}: a text with its own sprite asset") - return {} - if sprite is None: - raise RuntimeError(f"{path}: a UIText text without the emoji sprite asset") - return {"spriteAsset": sprite, "m_tintAllSprites": c["m_tintAllSprites"]} - - -def text_components(ex: Exporter, ui_doc: dict) -> dict[str, list[dict]]: - """{node path: the node's components} of every text node of the story UI (ui.json nodes with a text record), from - the prefabs advui assembles (UIAdvWidget with the talk windows of ui.json, the children of TalkView, under it).""" - widget, window = _ui_prefab_nodes(ex, ui_doc) - out = {} - for rec in ui_doc["nodes"]: - if "textStyle" not in rec: - continue - path = rec["path"] - hits = [n for n in (widget.get(path), window.get(path)) if n is not None] - if len(hits) != 1: - raise RuntimeError(f"{path}: {len(hits)} prefab nodes") - out[path] = hits[0]["components"] - return out def text_extras(path: str, comps: list[dict]) -> dict: @@ -362,145 +220,11 @@ def text_extras(path: str, comps: list[dict]) -> dict: return out -def _binding( - path: str, node: list[dict], swap: dict, lang: dict, mode: int, driven: bool = False, sprite: str | None = None -) -> dict: - """The text binding of a text node (its components `node`): the serialized TextMeshPro fields of its text - component, as advui writes them with fonts="game", the settings of its other text components (text_extras), - `localized` = textstyle.localize_text (LocalizeText) in the language `mode` and its sprite settings - (sprite_binding: `driven` by a UIText, `sprite` the emoji sprite asset's name).""" - comps = [c for c in node if c.get("class") in advui.TMP_CLASSES] - if len(comps) != 1: - raise RuntimeError(f"{path}: {len(comps)} text components") - c = comps[0] - t = {k: c[k] for k in advui.TMP_FIELDS if k in c} - t.update( - { - "class": c["class"], - "enabled": c["m_Enabled"], - "fontAsset": c["m_fontAsset"]["name"], - "material": c["m_sharedMaterial"]["material"], - } - ) - t.update(text_extras(path, node)) - t["localized"] = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode) - t.update(sprite_binding(path, node, driven, sprite)) - return t - - -def text_bindings(ex: Exporter, ui_doc: dict, lang: dict, mode: int, sprite: str | None = None) -> dict[str, dict]: - """{node path: text binding (_binding)} of every text node of the story UI (text_components); `sprite` = the - emoji sprite asset's name for the texts a UIText drives (story_ui_text_targets).""" - swap = textstyle.font_swap(lang, mode) - driven = story_ui_text_targets(ex, ui_doc) - return { - path: _binding(path, node, swap, lang, mode, path in driven, sprite) - for path, node in text_components(ex, ui_doc).items() - } - - -def dialog_bindings( - ex: Exporter, ui_doc: dict, lang: dict, mode: int, sprite: str | None = None -) -> dict[str, dict[str, dict]]: - """{dialog: {node path: text binding (_binding)}} of the text nodes of the dialogs of ui.json (`dialogs`, each - from its prefab `key`); `sprite` as text_bindings.""" - swap = textstyle.font_swap(lang, mode) - out: dict[str, dict[str, dict]] = {} - for name, d in sorted((ui_doc.get("dialogs") or {}).items()): - nodes = ex.prefab(d["key"])["nodes"] - prefab, driven = {n["path"]: n["components"] for n in nodes}, ui_text_targets(nodes) - for rec in d["nodes"]: - if "textStyle" in rec: - out.setdefault(name, {})[rec["path"]] = _binding( - rec["path"], prefab[rec["path"]], swap, lang, mode, rec["path"] in driven, sprite - ) - return out - - -def chat_bindings( - ex: Exporter, episode: dict, lang: dict, mode: int, sprite: str | None = None -) -> dict[str, dict[str, dict]]: - """{chat window: {node path: text binding}} of the TMP texts of the episode's chat windows (advui.chat_text_nodes): - the fields text_bindings gives, `localized` from advui.chat_localize for a text with LocalizeText enabled, else - the serialized font asset, material and line spacing; `sprite` as text_bindings.""" - out: dict[str, dict[str, dict]] = {} - nodes, _ = advui.chat_text_nodes(ex, episode) - driven = set().union( - *(ui_text_targets(ex.prefab(address)["nodes"]) for address in advui.chat_windows(episode).values()) - ) - for window, node, c, loc in nodes: - t = {k: c[k] for k in advui.TMP_FIELDS if k in c} - t.update( - { - "class": c["class"], - "enabled": c["m_Enabled"], - "fontAsset": c["m_fontAsset"]["name"], - "material": c["m_sharedMaterial"]["material"], - } - ) - t.update(text_extras(node["path"], node["components"])) - if loc and loc["m_Enabled"] and loc["_localizeEnabled"]: - t["localized"] = advui.chat_localize(t["fontAsset"], t["material"], lang, mode) - else: - t["localized"] = {"fontAsset": t["fontAsset"], "material": t["material"], "lineSpacing": c["m_lineSpacing"]} - t.update(sprite_binding(node["path"], node["components"], node["path"] in driven, sprite)) - out.setdefault(window, {})[node["path"]] = t - return out - - # the IAdvFrameTextReceiver implementations (AdvSystem.Asset.UI): a frame with one gets its texts at run time FRAME_TEXT_RECEIVERS = ("AdvSlanderCommentFrame",) FRAME_PREFIX = adv.RESOURCE_PREFIX["Frame"][1] -def frame_prefabs(ex: Exporter, episode: dict) -> dict[str, list[dict]]: - """{frame name (its address without the Frame prefix, the key of story/frames.json): its prefab node list} of the - episode's Frame resources in the catalog.""" - return { - r["address"][len(FRAME_PREFIX) :]: ex.prefab(r["address"])["nodes"] - for r in episode["resources"] - if r["kind"] == "frame" and r.get("present", True) - } - - -def frame_bindings( - frames: dict[str, list[dict]], lang: dict, mode: int, sprite: str | None = None -) -> dict[str, dict[str, dict]]: - """{frame name: {node path: text binding}} of the TMP texts of the episode's frames (`frames`: frame_prefabs), as - chat_bindings: `localized` from LocalizeText (advui.chat_localize) when it is enabled, else the serialized font - asset, material and line spacing; `sprite` as text_bindings (the texts a UIText drives).""" - out: dict[str, dict[str, dict]] = {} - for name, nodes in frames.items(): - driven = ui_text_targets(nodes) - for node in nodes: - comps = [c for c in node["components"] if c.get("class") in advui.TMP_CLASSES] - if not comps: - continue - c = comps[0] - loc = next((x for x in node["components"] if x.get("class") == "LocalizeText"), None) - t = {k: c[k] for k in advui.TMP_FIELDS if k in c} - t.update( - { - "class": c["class"], - "enabled": c["m_Enabled"], - "fontAsset": c["m_fontAsset"]["name"], - "material": c["m_sharedMaterial"]["material"], - } - ) - t.update(text_extras(node["path"], node["components"])) - if loc and loc["m_Enabled"] and loc["_localizeEnabled"]: - t["localized"] = advui.chat_localize(t["fontAsset"], t["material"], lang, mode) - else: - t["localized"] = { - "fontAsset": t["fontAsset"], - "material": t["material"], - "lineSpacing": c["m_lineSpacing"], - } - t.update(sprite_binding(node["path"], node["components"], node["path"] in driven, sprite)) - out.setdefault(name, {})[node["path"]] = t - return out - - def frame_texts(frames: dict[str, list[dict]], episode: dict, field: str) -> list[str]: """The texts the episode's frames (`frames`: frame_prefabs) show: the serialized texts of their text nodes, and the texts AdvFrameCommand.SetFrameTexts gives the frames with an IAdvFrameTextReceiver (FRAME_TEXT_RECEIVERS): per @@ -526,41 +250,12 @@ def frame_texts(frames: dict[str, list[dict]], episode: dict, field: str) -> lis if (c.get("TargetAssetName") or "").strip() not in receivers: continue for i in c["TargetTextIDs"]: - row = episode["text"].get(i) + row = episode["text"].get(i, episode["text"].get(str(i))) if row is not None and isinstance(row.get(field), str): out.append(unicodedata.normalize("NFC", row[field])) return serialized + (out + ["@\n"] if out else out) -def shown_texts(episode: dict, ui_doc: dict, master_dir: Path | None, field: str) -> list[str]: - """The texts the episode shows in the text field `field`: every text of its text table (lines, speaker names, - frame and choice texts), its title, the static labels of the UI (the MasterText rows of the text keys of the text - nodes and of the dialogs' text nodes, the `masterIdTexts` of ui.json) and of the chat windows - (`chatStatusTexts`), the serialized texts of the chat windows' text nodes (`chatTexts`, shown until the player - sets them) and the texts the chat windows format at run time (advui.chat_runtime_texts), markup included.""" - texts = [row.get(field) for row in episode["text"].values()] - texts.append((episode.get("title") or {}).get(field)) - texts += [t["text"] for t in (ui_doc.get("masterIdTexts") or {}).values()] - texts += list((ui_doc.get("chatStatusTexts") or {}).values()) + advui.chat_runtime_texts(episode) - texts += [rec["textStyle"].get("text") for w in (ui_doc.get("chatTexts") or {}).values() for rec in w.values()] - nodes = list(ui_doc["nodes"]) + [n for d in (ui_doc.get("dialogs") or {}).values() for n in d["nodes"]] - keys = {rec["textStyle"]["textKey"] for rec in nodes if "textStyle" in rec and rec["textStyle"].get("textKey")} - if keys: - if master_dir is None: - raise RuntimeError(f"UI text keys {sorted(keys)} need the master data") - rows = {r["_id"]: r for r in master.table(master_dir, "MasterText") if r["_id"] in keys} - missing = sorted(keys - set(rows)) - if missing: - raise KeyError(f"UI text keys not in MasterText: {missing}") - texts += [rows[k].get(f"_{field}") for k in sorted(keys)] - return [s for s in texts if isinstance(s, str)] - - -def shown_characters(episode: dict, ui_doc: dict, master_dir: Path | None, field: str) -> list[int]: - """The code points of shown_texts, sorted.""" - return sorted({ord(ch) for s in shown_texts(episode, ui_doc, master_dir, field) for ch in s}) - - def _material_textures(m: dict) -> None: """Texture references of a material as advui writes them: {name, width, height, format}.""" for v in m["textures"].values(): @@ -569,76 +264,6 @@ def _material_textures(m: dict) -> None: v["texture"] = {"name": t["name"], "width": t["width"], "height": t["height"], "format": t.get("format")} -def text_shaders(ex: Exporter, ui_dir: Path, materials: dict) -> dict[str, list[str]]: - """Add the shaders the text `materials` name to ui/shaders (index kept in name order) and return each - material's GLES3 keyword set (its keywords that the shader's GLES3 variants use; a vertex program with exactly - those must exist).""" - sdir = Path(ui_dir) / "shaders" - index = json.loads((sdir / "shaders.json").read_text(encoding="utf-8")) - have = {r["name"] for r in index} - for name in sorted({m["shader"]["shader"] for m in materials.values()}): - if name in have: - continue - if name not in ex.shaders: - raise RuntimeError(f"shader {name} not in the loaded bundles") - shader_mod.dump_objects([ex.shaders[name]], sdir, ex.shaders[name].assets_file.name, index) - index.sort(key=lambda r: r["name"]) - shader_mod.write_index(index, sdir) - recs = {r["name"]: r for r in index} - out = {} - for name, m in sorted(materials.items()): - rec = recs[m["shader"]["shader"]] - gles = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"] - known = {k for v in gles for k in v["keywords"]} - want = sorted(k for k in m["keywords"] if k in known) - if not any(sorted(v["keywords"]) == want and v["stage"] == "vertex" for v in gles): - raise RuntimeError(f"{rec['name']}: no GLES3 variant for material {name} keywords {want}") - out[name] = want - return out - - -def language_doc(language: str, source: str, fonts: dict, texts: dict, lang: dict) -> dict: - """ui/languages.json of `language`: LanguageMode, text field, line spacing and per font role the font asset - the role's texts use with its line metrics (textstyle.face_metrics).""" - mode = languages.mode(language) - roles: dict[str, dict] = {} - for _path, t in sorted(texts.items()): - role = textstyle.font_role(t["fontAsset"], lang) - fa = t["localized"]["fontAsset"] - if roles.get(role, {}).get("fontAsset", fa) != fa: - raise NotImplementedError(f"font role {role} uses two font assets ({roles[role]['fontAsset']}, {fa})") - f = fonts[fa] - roles[role] = { - "fontAsset": fa, - **textstyle.face_metrics(f["faceInfo"], {k: f[k] for k in ("normalSpacingOffset", "boldSpacing")}), - } - return { - "format": LANGUAGE_FORMAT, - "language": language, - "mode": mode, - "field": languages.column(language)[1:], - "lineSpacing": textstyle.LANGUAGE_LINE_SPACING[mode], - "fonts": source, - "roles": roles, - } - - -def _write(ui_dir: Path, fonts_doc: dict, lang_doc: dict) -> None: - (Path(ui_dir) / FONTS_DOC).write_bytes(_dump(fonts_doc)) - (Path(ui_dir) / LANGUAGE_DOC).write_bytes(_dump(lang_doc)) - - -def _exporter(cat, player, work: Path, font_stubs=None) -> Exporter: - return Exporter( - cat, - Path(work), - player=player, - textures="deferred", - stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"), - font_stubs=font_stubs, - ) - - # ---------------------------------------------------------------- open fonts PAGE_SETTINGS = {"m_FilterMode": 1, "m_Aniso": 1, "m_MipBias": 0.0, "m_WrapU": 1, "m_WrapV": 1, "m_WrapW": 1} SUBSET = ( @@ -784,23 +409,6 @@ def open_asset( } -def line_breaking(player) -> dict: - """TMP_Settings' line breaking rules: the text of its leading and following characters TextAssets (as stored) - and m_UseModernHangulLineBreakingRules.""" - o = player.resource("TMP Settings") - tt = player.mono(o) - - def text(key: str) -> str: - t = player.deref(o, tt[key]) - return t.read_typetree()["m_Script"] if t is not None else "" - - return { - "leading": text("m_leadingCharacters"), - "following": text("m_followingCharacters"), - "useModernHangulLineBreakingRules": bool(tt["m_UseModernHangulLineBreakingRules"]), - } - - def search_order(fallbacks_of, font: str, exported: set) -> list[str]: """The font assets TMP searches after `font` for a character `font` lacks, as tmpfont.FontSet.lookup (TMP_FontAssetUtilities.GetCharacterFromFontAsset_Internal: each fallback, then that fallback's own fallbacks, @@ -822,20 +430,6 @@ def walk(f: str) -> None: return out -def fallback_chains(ex: Exporter, primaries: set, exported: set) -> dict[str, list[str]]: - """{exported font asset: its search_order}, over the game's font assets reachable from the `primaries` (font - asset names): the fallback list of a font record once the assets that serve no shown character are left out.""" - fs = tmpfont.FontSet(ex) - for n in sorted(primaries): - fs.by_key(fontsettings.font_dir(n) + n) - missing = sorted(exported - set(fs.fonts)) - if missing: - raise RuntimeError(f"font assets {missing} are not reachable from {sorted(primaries)}") - return { - n: search_order(lambda f: [fb.name for fb in fs.fallbacks(fs.fonts[f])], n, exported) for n in sorted(exported) - } - - FALLBACK_FLOATS = ("_GradientScale", "_TextureWidth", "_TextureHeight", "_WeightNormal", "_WeightBold") SCALE_CLAMP = 1.0 # ShaderUtilities.m_clamp @@ -912,44 +506,24 @@ def fallback_material(m: dict, target: dict, name: str) -> dict: return out -def open_font_set( - cat, - player, - ex: Exporter, +def build_open_font_set( language: str, font: FontFile, bindings: list[dict], shown: list[str], - ui_dir: Path, + lang: dict, + missing_char: int, font_of=no_font_file, sprite_drawn: dict | None = None, margin_materials: dict | None = None, settings=None, ) -> dict: - """The open font assets of the text `bindings` (their `localized` game font asset and material, replaced in - place by the open asset and its material) in `language`, for the texts `shown`, with the game font assets of - `settings` (fontsettings.FontSettings; default: the built-in table): - - - per localized game font asset (a primary) an asset of `font` (the language's font file) with the game asset's - point size, padding, style settings, render mode and text materials (open_asset); - - the game asset's fallback chain mirrored: each asset of its search order (search_order) stands for an asset of - the font file of its counterpart_language (`font_of(language)` -> FontFile, asked for when a character - reaches it) with that game asset's settings; the game's own fallback assets no language lists are left out; - - each shown code point goes to the first asset of its primary's chain whose font file maps it, as - TMP_Text.GetTextElement finds a character; one no asset maps stays with the primary as `missing`. The code - points the game's chain lacks take the missing glyph (tmpfont.missing_glyph: the primary gets `missingGlyph`; - its substitute goes through the chain like any character) and those a sprite draws for the primary's texts - (`sprite_drawn` {game font asset: code points}) are left out; - - an asset's `fallbacks` are the assets of its chain that hold a character (in search order), and each text - material M of a primary has, per fallback of the primary, the fallback material "M + " - (fallback_material). - - `margin_materials` {game font asset: game material names}: further text materials of that asset whose underlay - the glyph margin covers (so the pages equal another document's with the same characters). The pages go to - `ui_dir`/fonts/. -> {fonts, textures, materials, missing (code points no asset maps), substituted (code points - drawn as a missing glyph)}.""" + """Generate open font records and PNG page bytes from explicit language and font settings. + + The localized font and material of each binding is updated to the generated asset. `pages` maps relative + file names to bytes; the other result fields are those of open_font_set. + """ settings = settings if settings is not None else fontsettings.builtin() - lang = textstyle.language_fonts(player) sprite_drawn = sprite_drawn or {} margin_materials = margin_materials or {} chars = sorted({ord(ch) for s in shown for ch in s}) @@ -957,7 +531,6 @@ def open_font_set( for t in bindings: by_game.setdefault(t["localized"]["fontAsset"], []).append(t) primaries = sorted(by_game) - missing_char = tmpfont.missing_glyph_character(player) files = {language: font} def file_of(code: str) -> FontFile: @@ -1024,7 +597,7 @@ def name_of(key: tuple[str, str]) -> str: if len(set(names.values())) != len(names): raise RuntimeError(f"open font asset names collide: {sorted(names.values())}") users = {k: [n for n in primaries if k in chain(n, language)] for k in keys} - fonts, textures, materials, renames = {}, {}, {}, {} + fonts, textures, materials, renames, pages = {}, {}, {}, {}, {} for key in keys: gname, code = key primary = gname in by_game and code == language @@ -1048,9 +621,8 @@ def name_of(key: tuple[str, str]) -> str: missing |= a["missing"] if primary: renames[gname] = a["renames"] - (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True) for pname, px in a["pages"]: - (ui_dir / a["textures"][pname]["texture"]).write_bytes(packed_png(px)) + pages[a["textures"][pname]["texture"]] = packed_png(px) # the fallback materials of the primaries' text materials, then the bindings for gname in primaries: prim = fonts[names[(gname, language)]] @@ -1068,105 +640,7 @@ def name_of(key: tuple[str, str]) -> str: "materials": materials, "missing": missing, "substituted": substituted, - } - - -def open_fonts( - cat, - player, - episode: dict, - ui_dir: Path, - language: str, - font: FontFile, - master_dir: Path | None = None, - emoji: EmojiFont | None = None, - font_of=no_font_file, -) -> dict: - """ui/fonts.json, ui/fonts/*.png and ui/languages.json of `language` from the font file `font` and the emoji font - `emoji` (module docstring; without one the sprites keep their metrics and have no texels), next to the - ui/ui.json advui.extract wrote for that language in `ui_dir`; the text shaders go to ui/shaders. `font_of`: - the font file of another language for the fallback chains (open_font_set). Returns a summary.""" - tmpfont.require_extra() - ui_dir = Path(ui_dir) - ui_doc = json.loads((ui_dir / "ui.json").read_text(encoding="utf-8")) - mode, field = languages.mode(language), languages.column(language)[1:] - settings = fontsettings.builtin() - ex = _exporter(cat, player, ui_dir / "_fonts", settings) - lang = textstyle.language_fonts(player) - sprite_name = emoji.asset_name if emoji is not None else NO_EMOJI_FONT - texts = text_bindings(ex, ui_doc, lang, mode, sprite_name) - dialogs = dialog_bindings(ex, ui_doc, lang, mode, sprite_name) - chat = chat_bindings(ex, episode, lang, mode, sprite_name) if ui_doc.get("chatTexts") else {} - frames = frame_prefabs(ex, episode) - frame = frame_bindings(frames, lang, mode, sprite_name) - shown = shown_texts(episode, ui_doc, master_dir, field) + frame_texts(frames, episode, field) - chars = sorted({ord(ch) for s in shown for ch in s}) - - bindings = list(texts.values()) + [ - t for group in (dialogs, chat, frame) for w in group.values() for t in w.values() - ] - # the code points the sprite asset draws for the texts with it (sprite_drawn); the open font asset of those - # texts leaves them out - sprite_drawn: dict[str, set[int]] = {} - rec = None - if any("spriteAsset" in t for t in bindings): - rec = advui.sprite_asset_record(ex) - sprite_drawn = drawn_by_sprites(rec, [t for t in bindings if "spriteAsset" in t], chars, settings.lookup) - # the open font assets with the mirrored fallback chains, the missing glyph and the fallback materials - fset = open_font_set( - cat, player, ex, language, font, bindings, shown, ui_dir, font_of, sprite_drawn, settings=settings - ) - fonts, textures, materials = fset["fonts"], fset["textures"], fset["materials"] - missing, substituted = fset["missing"], fset["substituted"] - coverage = {"characters": len(chars), "missing": [chr(u) for u in sorted(missing)]} - if substituted: - coverage["missingGlyph"] = [chr(u) for u in sorted(substituted)] - sprites = None - indices = sprite_needs(rec, shown, set().union(*sprite_drawn.values())) if rec is not None else [] - if rec is None or not indices: # no sprite to draw: the data holds no sprite asset - for t in bindings: - t.pop("spriteAsset", None) - t.pop("m_tintAllSprites", None) - else: - sprites = open_sprite_asset(emoji, rec, indices, sprite_name) - for pname, px in sprites["pages"]: - (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True) - (ui_dir / sprites["textures"][pname]["texture"]).write_bytes(packed_png(px)) - textures.update(sprites["textures"]) - materials[sprites["material"]["material"]] = sprites["material"] - coverage["sprites"] = {"characters": len(sprites["asset"]["characters"]), "missing": sprites["missing"]} - keywords = text_shaders(ex, ui_dir, materials) - fonts_doc = { - "format": FONTS_FORMAT, - "language": language, - "source": "open", - "fonts": fonts, - "textures": textures, - "materials": materials, - "materialKeywords": keywords, - "texts": texts, - "coverage": coverage, - "lineBreaking": line_breaking(player), - } - if sprites is not None: - fonts_doc["spriteAssets"] = {sprite_name: sprites["asset"]} - fonts_doc["emojiSpriteAsset"] = sprite_name - if dialogs: - fonts_doc["dialogTexts"] = dialogs - if chat: - fonts_doc["chatTexts"] = chat - if frame: - fonts_doc["frameTexts"] = frame - _write(ui_dir, fonts_doc, language_doc(language, "open", fonts, texts, lang)) - shutil.rmtree(ui_dir / "_fonts", ignore_errors=True) - return { - "fonts": sorted(fonts), - "characters": len(chars), - "missing": len(missing), - "glyphs": sum(len(f["glyphs"]) for f in fonts.values()), - "pages": len(textures), - "sprites": len(sprites["asset"]["characters"]) if sprites else 0, - "missingSprites": len(sprites["missing"]) if sprites else 0, + "pages": pages, } @@ -1201,12 +675,12 @@ class EmojiFont: Font License 1.1): the image of an emoji, a code point or a sequence resolved through the font's ligature substitutions (with U+FE0F dropped when the full sequence has no ligature), from the largest strike.""" - def __init__(self, path, face_index: int = 0): + def __init__(self, path, face_index: int = 0, *, data: bytes | None = None): import io from fontTools.ttLib import TTFont - self.file = FontFile(path, face_index) + self.file = FontFile(path, face_index, data=data) self.tt = TTFont(io.BytesIO(self.file.data), fontNumber=face_index) self.cmap = self.tt.getBestCmap() or {} self.ligatures = _ligatures(self.tt) @@ -1399,155 +873,3 @@ def open_sprite_asset(emoji: EmojiFont | None, game: dict, indices: list[int], n # ---------------------------------------------------------------- game fonts -def game_sprite_asset(ex: Exporter, ui_dir: Path, rec: dict) -> dict: - """LocalizeManager's emoji sprite asset from the game data (`rec`, advui.sprite_asset_record): every character and - glyph, the sprite sheet as one page (ui/fonts/, the glyph rects unchanged) and its material sampling it. - -> {asset_name, asset, textures, material}.""" - sh = rec["sheet"] - if sh["mipCount"] != 1: - raise NotImplementedError(f"{rec['name']}: sprite sheet with {sh['mipCount']} mip levels") - o = ex.key_object(advui.EMOJI_SPRITE_ASSET_KEY) - sheet = ex.referent(o, o.read_typetree()["spriteSheet"]) - page = f"sprite_{rec['name']}" - dst = f"{PAGES_DIR}/{_safe(page)}.png" - (Path(ui_dir) / PAGES_DIR).mkdir(parents=True, exist_ok=True) - (Path(ui_dir) / dst).write_bytes(texture_png(sheet.read())) - glyphs = { - k: { - **g, - **( - {"packed": {"texture": page, "dx": 0, "dy": 0}} - if g["rect"]["m_Width"] > 0 and g["rect"]["m_Height"] > 0 - else {} - ), - } - for k, g in rec["glyphs"].items() - } - material = _sheet_material(rec["material"], rec["material"]["material"], page, sh["width"], sh["height"]) - asset = { - "faceInfo": rec["faceInfo"], - "characters": rec["characters"], - "glyphs": glyphs, - "sequences": rec["sequences"], - "material": material["material"], - } - textures = { - page: { - "texture": dst, - "name": sh["name"], - "width": sh["width"], - "height": sh["height"], - "mipCount": 1, - "settings": sh["settings"], - } - } - return {"asset_name": rec["name"], "asset": asset, "textures": textures, "material": material} - - -def game_fonts( - cat, - player, - game_ui_dir: Path, - ui_dir: Path, - language: str, - episode: dict | None = None, - master_dir: Path | None = None, -) -> dict: - """ui/fonts.json, ui/fonts/*.png and ui/languages.json of `language` from the ui/ directory advui.extract wrote - with fonts="game" for that language (`game_ui_dir`): its font records (each font's `fallbacks` reduced to the - exported assets in search order, fallback_chains), glyph pages, text materials, keyword sets, text records, glyph - coverage and TMP settings, next to the fonts-open ui/ui.json in `ui_dir`; the text shaders go to ui/shaders as - with open fonts. With the `episode` (and the master data for its UI labels), the texts a UIText drives get the - game's emoji sprite asset when they draw a sprite (sprite_needs; its code points leave coverage.missing). Returns a - summary.""" - game_ui_dir, ui_dir = Path(game_ui_dir), Path(ui_dir) - doc = json.loads((game_ui_dir / "ui.json").read_text(encoding="utf-8")) - ui_doc = json.loads((ui_dir / "ui.json").read_text(encoding="utf-8")) - texts = {n["path"]: n["text"] for n in doc["nodes"] if "text" in n} - open_paths = {n["path"] for n in ui_doc["nodes"] if "textStyle" in n} - if set(texts) != open_paths: - raise RuntimeError("the text nodes of the fonts-game and fonts-open UI differ") - fonts = doc["fonts"] - pages = sorted({g["packed"]["texture"] for f in fonts.values() for g in f["glyphs"].values() if "packed" in g}) - textures = {} - (ui_dir / PAGES_DIR).mkdir(parents=True, exist_ok=True) - for p in pages: - t = dict(doc["textures"][p]) - dst = f"{PAGES_DIR}/{Path(t['texture']).name}" - shutil.copyfile(game_ui_dir / t["texture"], ui_dir / dst) - t["texture"] = dst - textures[p] = t - names = {t["localized"]["material"] for t in texts.values()} | {f["material"] for f in fonts.values()} - materials = {n: doc["materials"][n] for n in sorted(names)} - ex = _exporter(cat, player, ui_dir / "_fonts") - chains = fallback_chains(ex, {t["localized"]["fontAsset"] for t in texts.values()}, set(fonts)) - fonts = {n: {**f, "fallbacks": chains[n]} for n, f in fonts.items()} - for t in texts.values(): # TMP's fallback materials of the text materials (fallback_material) - loc = t["localized"] - for fb in fonts[loc["fontAsset"]]["fallbacks"]: - name = f"{loc['material']} + {fb}" - if name not in materials: - materials[name] = fallback_material(materials[loc["material"]], materials[fonts[fb]["material"]], name) - driven = story_ui_text_targets(ex, ui_doc) & set(texts) - sprites, drawn = None, set() - if episode is not None and driven: - rec = advui.sprite_asset_record(ex) - shown = shown_texts(episode, ui_doc, master_dir, languages.column(language)[1:]) - chars = sorted({ord(ch) for s in shown for ch in s}) - fs = tmpfont.FontSet(ex) - - def lookup(name, u): - return fs.lookup(fs.by_key(fontsettings.font_key(ex.cat, name, name)), u) - - drawn = set().union(*drawn_by_sprites(rec, [texts[p] for p in sorted(driven)], chars, lookup).values()) - if sprite_needs(rec, shown, drawn): - sprites = game_sprite_asset(ex, ui_dir, rec) - if sprites is None: - driven, drawn = set(), set() - sprite_name = sprites["asset_name"] if sprites else None - for path, comps in text_components(ex, ui_doc).items(): - texts[path] = { - **texts[path], - **text_extras(path, comps), - **sprite_binding(path, comps, path in driven, sprite_name), - } - if sprites is not None: - textures.update(sprites["textures"]) - materials[sprites["material"]["material"]] = sprites["material"] - for n in sorted({t["localized"]["material"] for t in texts.values()}): # the text shaders for text_shaders - fa = next(t["localized"]["fontAsset"] for t in texts.values() if t["localized"]["material"] == n) - ex.material(ex.key_object(fontsettings.font_dir(fa) + n)) - keywords = text_shaders(ex, ui_dir, materials) - cov = doc["glyphCoverage"] - if sprites is not None: - cov = { - **cov, - "missing": [c for c in cov["missing"] if ord(c) not in drawn], - "sprites": {"characters": len(sprites["asset"]["characters"]), "missing": []}, - } - fonts_doc = { - "format": FONTS_FORMAT, - "language": language, - "source": "game", - "fonts": fonts, - "textures": textures, - "materials": materials, - "materialKeywords": keywords, - "texts": texts, - "coverage": cov, - "tmpSettings": doc["tmpSettings"], - "lineBreaking": line_breaking(player), - } - if sprites is not None: - fonts_doc["spriteAssets"] = {sprite_name: sprites["asset"]} - fonts_doc["emojiSpriteAsset"] = sprite_name - lang = textstyle.language_fonts(player) - _write(ui_dir, fonts_doc, language_doc(language, "game", fonts, texts, lang)) - shutil.rmtree(ui_dir / "_fonts", ignore_errors=True) - return { - "fonts": sorted(fonts), - "characters": cov["characters"], - "missing": len(cov["missing"]), - "glyphs": sum(len(f["glyphs"]) for f in fonts.values()), - "pages": len(textures), - } diff --git a/src/nnnotes/content/storyhost.py b/src/nnnotes/content/storyhost.py deleted file mode 100644 index dc6cd33..0000000 --- a/src/nnnotes/content/storyhost.py +++ /dev/null @@ -1,1503 +0,0 @@ -"""The host screen of an Overlay story (MasterAdv._playbackMode 1) -> /host/ and /ui/simple/. - -The game plays these episodes with App.Adv.SimpleAdvPlayer inside the screen that starts them, not in the ADV scene: -SimpleAdvView lays the characters out in the slots of the host's SimpleAdvLayoutRoot (each drawn by its own -CameraTargetRenderer) and attaches the UISimpleAdvTalkWindow under the root's TalkRoot. The host is the story group -of the episode: a tap talk (MasterStoryHomeSpotTapTalkEpisode) or an area talk (MasterHomeSpot._advId) of a home -spot (SpotManager), or the reward phase of a live result (LiveResultDisplayPresenter). - - host/host.json kind, the capture camera of a slot (CameraTargetRenderer prefab), the SimpleAdv root and its - SimpleAdvLayoutRoot fields, the sequences the host screen has played when the talk starts, - and per kind: - home spot.json + Spine files (spot.extract) and room.glb + room.json - (room.extract_room) under host/spot/; per placed glb node its object path, its - state after SpotBackground.Prepare and the SpotSceneRoot.SetObject "floor" render - queue; the room materials as saved; the "Spot" scene's object root, lights, - volumes and RenderSettings; the lights of the placed prefabs; the background - Volume; the Spine materials; the blur of renderer 0's UIRendererFeature; the - situation's ambient sound; shaders in host/shaders/ - afterlive the result background node and sprite, the reward panel, the substitutes - host/ui/ui.json the host UI in the advui record format (no text nodes) - ui/simple/ui.json per language: UISimpleAdvTalkWindow (and UISystemMessageWidget for an area talk) in the - advui record format with text records, fonts.json and glyph pages (storyfonts format, open - fonts) - -Same inputs give the same bytes. -""" - -from __future__ import annotations - -import json -import shutil -import struct -from pathlib import Path - -import numpy as np -import UnityPy - -from ..core.jsonio import dumps, write_json -from ..formats import shader as shader_mod -from ..source import languages, master -from ..unity import fontsettings, textstyle, tmpfont -from ..unity.export import RECT_PROPS, Exporter, TexelPacker, streamed_frames -from ..unity.reader import SceneGraph, deref, mesh_arrays, referent, script_class -from . import advui, room, spot, storyfonts - -FORMAT = "ournotes.story-host/1" -OVERLAY = 1 # MasterAdv._playbackMode of the episodes SimpleAdvPlayer plays -HOST_DIR = "host" -HOST_DOC = f"{HOST_DIR}/host.json" -HOST_UI_DOC = f"{HOST_DIR}/ui/ui.json" -SIMPLE_DIR = "simple" -SIMPLE_DOC = f"ui/{SIMPLE_DIR}/ui.json" -FONT_MODES = ("open", "game") -TMP_STUBS = ("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet") -# game addresses; a missing one is loaded from "Emb" + address (emb_key) -ADDRESSES = { - "cameraTarget": "Common/Prefab/CameraTargetRenderer", - "talkWindow": "UI/Prefab/Parts/Adv/Talk/UISimpleAdvTalkWindow", - "systemMessage": "UI/Prefab/UISystemMessageWidget", - "spotWidget": "UI/Prefab/UISpotWidget", - "spotLetterbox": "UI/Prefab/Parts/Spot/UISpotLetterbox", - "spotScene": "Scene/Spot", - "resultWidget": "UI/Prefab/UILiveResultWidget", - "resultBackground": "UI/Prefab/UILiveResultBackgroundWidget", - "resultBackgroundSprite": "LowImage/LiveResult/live_result_background", -} -KIND_KEYS = { - "home": ("spotWidget", "spotLetterbox", "spotScene"), - "afterlive": ("resultWidget", "resultBackground", "resultBackgroundSprite"), -} -# story group kind (storysite.story_groups) -> (host kind, talk) -HOST_GROUPS = {"spotTalk": ("home", "tap"), "spot": ("home", "area"), "liveResult": ("afterlive", None)} -SUBSTITUTES = ["musicResultPanel", "rewardItems", "memberCard", "navigation", "gradeUpEffect"] -CLONE = "(Clone)" # Object.Instantiate's name suffix, removed by SpotBackground.Prepare -FLOOR_NAME, FLOOR_EXCLUDE, FLOOR_QUEUE = "floor", "shadow", 2000 # SpotSceneRoot.SetObject -REWARD_PANEL = "RewardPanel" -COMPONENT_HEADER = ("type", "class", "m_Name") -NO_EPISODE = {"text": {}, "commands": []} # the episode argument of storyfonts.shown_characters for a UI alone -DOTWEEN_FIELDS = ( - "defaultEaseType", - "defaultUpdateType", - "defaultTimeScaleIndependent", - "timeScale", - "defaultEaseOvershootOrAmplitude", - "defaultEasePeriod", -) -# components that light the renderers of a scene (lighting_components) -LIGHTING_TYPES = ("Light", "ReflectionProbe") -# RenderSettings values of the active scene the renderers' lighting depends on; object references by name -# (render_settings) -RENDER_SETTINGS_FIELDS = ( - "m_Fog", - "m_AmbientMode", - "m_AmbientSkyColor", - "m_AmbientEquatorColor", - "m_AmbientGroundColor", - "m_AmbientIntensity", - "m_SubtractiveShadowColor", - "m_DefaultReflectionMode", - "m_DefaultReflectionResolution", - "m_ReflectionBounces", - "m_ReflectionIntensity", - "m_UseRadianceAmbientProbe", - "m_IndirectSpecularColor", -) -RENDER_SETTINGS_REFS = ("m_SkyboxMaterial", "m_CustomReflection", "m_Sun") - - -# ---------------------------------------------------------------- host, keys -def host_of(groups: list[dict]) -> dict: - """The host of an Overlay story from its story groups (storysite.story_groups): a spotTalk group is a home tap - talk (SpotManager.OnTalkCharacter: spot and tapped SpotCharacter id), a spot group a home area talk - (SpotManager.PlayAreaTalkAsync), a liveResult group the live result reward phase. Exactly one such group.""" - hits = [g for g in groups if g.get("kind") in HOST_GROUPS] - if len(hits) != 1: - raise ValueError( - f"an Overlay story needs exactly one spotTalk, spot or liveResult group, not {len(hits)} " - f"(groups: {[g.get('kind') for g in groups]})" - ) - g = hits[0] - kind, talk = HOST_GROUPS[g["kind"]] - out = {"kind": kind, "group": {"kind": g["kind"], "id": g["id"]}} - if kind == "home": - chars = g.get("characters") or [] - out.update(spotId=g["spot"]["id"], talk=talk, characterId=chars[0]["id"] if talk == "tap" and chars else None) - return out - - -def emb_key(cat, address: str) -> str: - """The catalog key of a game address: the address itself, else the embedded copy "Emb" + address.""" - for key in (address, f"Emb{address}"): - if cat.has(key): - return key - raise KeyError(f"{address}: neither it nor Emb{address} is in the catalog") - - -def _parent(path: str) -> str: - return path.rsplit("/", 1)[0] if "/" in path else "" - - -def _trs(node: dict) -> dict: - return {k: node[k] for k in ("localPosition", "localRotation", "localScale")} - - -def _first(nodes: list[dict], path: str) -> dict: - hits = [n for n in nodes if n["path"] == path] - if len(hits) != 1: - raise RuntimeError(f"{path}: {len(hits)} nodes") - return hits[0] - - -# ---------------------------------------------------------------- records -def camera_target(nodes: list[dict]) -> dict: - """cameraTarget: the CameraTargetRenderer prefab (SimpleAdvView slot capture): the transforms of the parent of - `_stage` and `_captureCamera` (Container), of the stage and of the capture camera with its Camera and - UniversalAdditionalCameraData settings (the slot's RenderTexture, layer and stage offset are code).""" - crt = advui._component(nodes[0], ("CameraTargetRenderer",)) - if crt is None: - raise RuntimeError(f"{nodes[0]['path']}: no CameraTargetRenderer") - stage, cam = advui._ref_path(crt["_stage"]), advui._ref_path(crt["_captureCamera"]) - if not stage or not cam or _parent(cam) != _parent(stage): - raise RuntimeError("CameraTargetRenderer: stage and capture camera not under one parent") - container = _parent(stage) - node = _first(nodes, cam) - c = [x for x in node["components"] if x["type"] == "Camera"] - d = [x for x in node["components"] if x.get("class") == "UniversalAdditionalCameraData"] - if len(c) != 1 or len(d) != 1: - raise RuntimeError(f"{cam}: {len(c)} Camera, {len(d)} UniversalAdditionalCameraData") - c, d = c[0], d[0] - return { - "container": {"path": container, **_trs(_first(nodes, container))}, - "stage": {"path": stage, **_trs(_first(nodes, stage))}, - "camera": { - "path": cam, - "localPosition": node["localPosition"], - "localRotation": node["localRotation"], - "fieldOfView": c["field of view"], - "near": c["near clip plane"], - "far": c["far clip plane"], - "orthographic": bool(c["orthographic"]), - "orthographicSize": c["orthographic size"], - "clearFlags": c["m_ClearFlags"], - "backgroundColor": c["m_BackGroundColor"], - "hdr": bool(c["m_HDR"]), - "allowMSAA": bool(c["m_AllowMSAA"]), - "rendererIndex": d["m_RendererIndex"], - "renderPostProcessing": bool(d["m_RenderPostProcessing"]), - "antialiasing": d["m_Antialiasing"], - }, - } - - -def _fields(c: dict) -> dict: - return {k: v for k, v in c.items() if k not in COMPONENT_HEADER} - - -def layout_root(nodes: list[dict], path: str) -> dict: - """layoutRoot: the serialized fields of the SimpleAdvLayoutRoot on the node at `path` (the profile - SimpleAdvLayoutRoot.CreateProfile makes).""" - c = advui._component(_first(nodes, path), ("SimpleAdvLayoutRoot",)) - if c is None: - raise RuntimeError(f"{path}: no SimpleAdvLayoutRoot") - return _fields(c) - - -def kept(path: str, subtrees=(), singles=()) -> bool: - """A node kept in a host UI: inside one of `subtrees` (root included), one of `singles`, or an ancestor of - either.""" - return any(path == s or path.startswith(s + "/") for s in subtrees) or any( - s == path or s.startswith(path + "/") for s in (*subtrees, *singles) - ) - - -# serialized references kept as node paths: component class -> (record key, fields) -REF_RECORDS = { - "UIAdvTalkWindow": ( - "talkWindowRefs", - ( - "SpeakerText", - "TalkText", - "TalkArea", - "_speakerNameObjects", - "_talkNextIndicator", - "_talkBackground", - "_autoIcon", - "_fastIcon", - ), - ), - "UISystemMessage": ("systemMessage", ("_message", "_showAnimation", "_canvasGroup", "_animator")), - "SpotLetterboxBands": ("letterboxBands", ("_topBand", "_bottomBand", "_leftBand", "_rightBand")), -} -WIDGET_FIELDS = ("_canvasSortOrder", "_isMainWidget", "_useBlur", "_blurDuration", "_useGlassMorphism") -TWEEN_EVENT = 1 # DOTweenSequence command type of an event entry (persistent calls) - - -def _plain(v): - """A value with serialized references ({gameObject | transform}) as node paths.""" - if isinstance(v, list): - return [_plain(x) for x in v] - if isinstance(v, dict) and ("gameObject" in v or "transform" in v): - return advui._ref_path(v) - return v - - -def tween_calls(entry: dict) -> list[dict]: - """The persistent calls of a DOTweenSequence event entry (UnityEvent m_PersistentCalls).""" - calls = (((entry.get("_tweenEvent") or {}).get("m_PersistentCalls") or {}).get("m_Calls")) or [] - out = [] - for x in calls: - t, a = x.get("m_Target") or {}, x.get("m_Arguments") or {} - out.append( - { - "target": advui._ref_path(t), - "class": t.get("class") or t.get("component"), - "method": x.get("m_MethodName"), - "string": a.get("m_StringArgument"), - "float": a.get("m_FloatArgument"), - "int": a.get("m_IntArgument"), - "bool": a.get("m_BoolArgument"), - "mode": x.get("m_Mode"), - } - ) - return out - - -def node_record(node: dict) -> dict: - """The advui record of a prefab node (transform, rect, advui._layout_components) with the records of the - components the host screens add: aspectRatioFitter, simpleAdvLayoutRoot, safeArea (UISafeArea), the - references of UIAdvTalkWindow / UISystemMessage / SpotLetterboxBands as node paths, `widget` (the UIManager - widget fields of a *Widget component) and, per DOTweenSequence event entry, its persistent `calls`.""" - rec = { - "path": node["path"], - "name": node["name"], - "active": node["active"], - "localPosition": node["localPosition"], - "localRotation": node["localRotation"], - "localScale": node["localScale"], - "rect": node.get("rect"), - } - rec.update(advui._layout_components(node)) - for c in node["components"]: - cls = c.get("class") - if cls == "AspectRatioFitter": - rec["aspectRatioFitter"] = {k: c[k] for k in ("m_Enabled", "m_AspectMode", "m_AspectRatio")} - elif cls == "SimpleAdvLayoutRoot": - rec["simpleAdvLayoutRoot"] = _fields(c) - elif cls == "UISafeArea": - rec["safeArea"] = {k: _plain(v) for k, v in _fields(c).items()} - elif cls in REF_RECORDS: - key, names = REF_RECORDS[cls] - rec[key] = {k: _plain(c.get(k)) for k in names} - elif cls == "DOTweenSequence": - for entry, out in zip(c["_list"], rec["tweenSequence"]["list"]): - if entry.get("_commandType") == TWEEN_EVENT: - out["calls"] = tween_calls(entry) - elif cls and cls.endswith("Widget") and "_canvasSortOrder" in c: - rec["widget"] = {"class": cls, **{k: c[k] for k in WIDGET_FIELDS if k in c}} - return rec - - -# ---------------------------------------------------------------- clips, shaders -TRANSFORM_CURVES = {"m_LocalPosition": "xyz", "m_LocalScale": "xyz", "m_LocalRotation": "xyzw"} - - -def clip_view(ex: Exporter, o, graph, root: str) -> dict: - """An AnimationClip played by the Animator at `root` as Exporter.clip_curves writes it, bindings below the - animated root included: their properties are keyed ":". Properties: - RectTransform driven fields, CanvasGroup.m_Alpha, Transform local position / rotation / scale / euler angles, - GameObject.m_IsActive and MonoBehaviour fields ("."). Streamed curves as keys, dense curves as - {samples, sampleRate, beginTime} (the dense clip's frames), constant curves as {constant}; the discrete curves - that follow the streamed float curves in the stream (a sprite reference's ".m_Sprite": the value indexes - `pptrCurveMapping`, the names of the referenced objects) as {discrete: [[time, value]]}, the value held until - the next key. A binding whose path names no object of the prefab (the Animator ignores it) is keyed - "#:" and its crc listed in `unresolvedPaths`.""" - rec = ex.clip_record(o) - ex.bind_clip(o, graph, root) - tt, mc, st, de = rec["tt"], rec["muscle"], rec["streamed"], rec["dense"] - name, const = rec["name"], rec["constant"] - n_st, n_disc, n_de = st["curveCount"], st.get("discreteCurveCount", 0), de["m_CurveCount"] - if n_disc and n_de: - raise NotImplementedError(f"clip {name}: discrete and dense curves") - props, lost = [], [] - for r in rec["bindings"]: - if r["int"] or r["serializeReference"]: - raise NotImplementedError(f"clip {name}: binding {r['raw']}") - if r["path"] is None: - lost.append(r["pathCrc"]) - pre = f"#{r['pathCrc']}:" if r["path"] is None else f"{r['path']}:" if r["path"] else "" - tid, attr = r["typeID"], r["attribute"] - if tid == 224 and attr in RECT_PROPS: - names = ["RectTransform." + attr] - elif tid == 225 and attr == "m_Alpha": - names = ["CanvasGroup.m_Alpha"] - elif tid == 4 and r["attributeCrc"] == 4: # localEulerAnglesRaw - names = [f"Transform.localEulerAngles.{a}" for a in "xyz"] - elif tid == 4 and r["transformAttr"] and attr in TRANSFORM_CURVES: - names = [f"Transform.{attr}.{a}" for a in TRANSFORM_CURVES[attr]] - elif tid == 1 and attr == "m_IsActive": - names = ["GameObject.m_IsActive"] - elif tid == 114 and r["script"] and attr: - names = [f"{r['script'][1]}.{attr}"] - elif r["pptr"] and attr == "m_Sprite" and tid == 212: - names = ["SpriteRenderer.m_Sprite"] - else: - raise NotImplementedError(f"clip {name}: binding {r['raw']}") - if r["pptr"] != (n_st <= len(props) < n_st + n_disc): - raise NotImplementedError(f"clip {name}: reference curve {r['raw']} outside the discrete curves") - props += [pre + n for n in names] - if n_st + n_disc + n_de + len(const) != len(props): - raise RuntimeError(f"clip {name}: curve count != bound properties") - curves = {p: {"keys": []} for p in props[:n_st]} - curves.update({p: {"discrete": []} for p in props[n_st : n_st + n_disc]}) - for t, keys in streamed_frames(st["data"]): - for ci, c0, c1, c2, c3 in keys: - if ci < n_st: - curves[props[ci]]["keys"].append([t, c0, c1, c2, c3]) - else: - curves[props[ci]]["discrete"].append([t, c3]) - samples = list(de["m_SampleArray"]) - for j in range(n_de): - curves[props[n_st + j]] = { - "samples": samples[j::n_de][: de["m_FrameCount"]], - "sampleRate": de["m_SampleRate"], - "beginTime": de["m_BeginTime"], - } - for i, v in enumerate(const): - curves[props[n_st + n_disc + n_de + i]] = {"constant": v} - out = { - "name": name, - "sampleRate": tt["m_SampleRate"], - "startTime": mc["m_StartTime"], - "stopTime": mc["m_StopTime"], - "loopTime": bool(mc["m_LoopTime"]), - "events": tt["m_Events"], - "curves": curves, - "keyFormat": "[time, c0, c1, c2, c3]; value(t) = ((c0*x + c1)*x + c2)*x + c3, x = t - time, " - "from the last key with time <= t (first key at -FLT_MAX)", - } - if rec["pptrCurveMapping"]: - refs = [ex.deref(o, p) for p in rec["pptrCurveMapping"]] - out["pptrCurveMapping"] = [x.read_typetree().get("m_Name") if x is not None else None for x in refs] - if lost: - out["unresolvedPaths"] = lost - return out - - -def gles3_keywords(index: list, shader: str, keywords, what: str) -> list[str]: - """The keywords of `keywords` that the GLES3 variants of `shader` use; a GLES3 vertex program with exactly - those must exist (as advui checks its materials).""" - rec = next((r for r in index if r["name"] == shader), None) - if rec is None: - raise RuntimeError(f"shader {shader} of {what} not dumped") - gles = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"] - known = {k for v in gles for k in v["keywords"]} - want = sorted(k for k in keywords if k in known) - if not any(sorted(v["keywords"]) == want and v["stage"] == "vertex" for v in gles): - raise RuntimeError(f"{shader}: no GLES3 variant for {what} keywords {want}") - return want - - -def dump_gles3(objects: dict, out_dir: Path) -> tuple[list, dict]: - """Dump the Shader objects {name: object} in name order (shader.py layout) keeping only the GLES3 programs: - the index lists those, the other programs are not kept. -> (index, write_index summary).""" - index: list = [] - for name in sorted(objects): - shader_mod.dump_objects([objects[name]], out_dir, objects[name].assets_file.name, index) - for rec in index: - keep = [v for v in rec["variants"] if v["platform"] == "gles3" and v["type"] == "GLES3"] - for v in rec["variants"]: - if v not in keep: - (Path(out_dir) / v["file"]).unlink(missing_ok=True) - rec["variants"] = keep - for d in sorted(Path(out_dir).rglob("*"), reverse=True): - if d.is_dir() and not any(d.iterdir()): - d.rmdir() - return index, shader_mod.write_index(index, out_dir) - - -# ---------------------------------------------------------------- UI documents -class UiDoc: - """A ui.json of prefab parts in the advui record format, written into `out_dir`: node records (node_record; - a TMP text gets its advui text record `textStyle` in the LanguageMode `mode`, None: no text allowed), the packed - sprites and textures, the materials with their GLES3 keyword sets, the UI shaders, the DOTween defaults, and - per Animator its controller (Exporter.controller_states) and clips (clip_view); a node whose controller cannot - be exported loses its `animator` record and is listed in `animatorsDropped`.""" - - def __init__(self, cat, player, out_dir: Path, mode: int | None = None): - self.player, self.dir = player, Path(out_dir) - self.dir.mkdir(parents=True, exist_ok=True) - self.ex = Exporter( - cat, self.dir, player=player, textures="deferred", stub_assets=TMP_STUBS, font_stubs=fontsettings.builtin() - ) - self.packer = TexelPacker(self.dir) - self.styles = textstyle.TextStyles(self.ex, player, mode, fontsettings.builtin()) if mode is not None else None - self.nodes: list[dict] = [] - self.sources: list[tuple[str, dict]] = [] # (key, prefab node) of each record - self.roots: list[dict] = [] - self.doc: dict | None = None - - def prefab(self, key: str) -> list[dict]: - return self.ex.prefab(key)["nodes"] - - def add(self, key: str, nodes: list[dict], subtrees=None, singles=()) -> list[dict]: - """Records of the prefab `nodes` of `key` (every node, or those `kept` by subtrees / singles).""" - out = [] - for n in nodes: - if subtrees is not None and not kept(n["path"], subtrees, singles): - continue - rec = node_record(n) - if "text" in rec: - if self.styles is None: - raise RuntimeError(f"{n['path']}: a text node in a UI without texts") - if not (rec.get("localizeText") or {}).get("localizeEnabled"): - raise NotImplementedError(f"{n['path']}: text without LocalizeText") - rec["textStyle"] = self.styles.record( - n["path"], advui._need_component(n, advui.TMP_CLASSES), advui._component(n, ("LocalizeText",)) - ) - del rec["text"] - self.nodes.append(rec) - self.sources.append((key, n)) - out.append(rec) - self.roots.append({"root": nodes[0]["path"], "key": key}) - return out - - def sprite(self, key: str) -> dict: - """{spriteRef, name} of the sprite a key names (a Sprite, or a Texture2D's sprite sub-asset).""" - o = self.ex.key_object(key) - return self.ex.sprite(o) if o.type.name == "Sprite" else self.ex.key_sprite(key) - - def _controller(self, key: str, path: str): - env, graph = self.ex.load(key) - hits = [] - for o in env.objects: - if o.type.name == "Animator": - tf = graph.tf_of_go.get(o.read_typetree()["m_GameObject"]["m_PathID"]) - if tf is not None and graph.path(tf) == path: - hits.append(o) - if len(hits) != 1: - return None - ctrl = self.ex.deref(hits[0], hits[0].read_typetree()["m_Controller"]) - if ctrl is None: - return None - try: - states = self.ex.controller_states(ctrl) - clips = {} - for p in ctrl.read_typetree()["m_AnimationClips"]: - c = self.ex.deref(ctrl, p) - if c is None or c.type.name != "AnimationClip": - raise NotImplementedError("not an AnimationClip") - v = clip_view(self.ex, c, graph, path) - clips[f"{states['name']}/{v['name']}"] = v - except NotImplementedError: - return None - return states, clips - - def write(self, about: str, head: dict | None = None, tail: dict | None = None) -> dict: - clips, controllers, dropped = {}, {}, [] - for rec, (key, n) in zip(self.nodes, self.sources): - if "animator" in rec: - got = self._controller(key, n["path"]) - if got is None: - del rec["animator"] - dropped.append(rec["path"]) - else: - controllers[got[0]["name"]] = got[0] - clips.update(got[1]) - ex, refs = self.ex, set() - for rec in self.nodes: - if (rec.get("image") or {}).get("spriteRef"): - refs.add(rec["image"]["spriteRef"]) - if (rec.get("buttonImageState") or {}).get("selectedRef"): - refs.add(rec["buttonImageState"]["selectedRef"]) - for ref in sorted(refs): - s = ex.sprite_recs[ref] - tex = s["tex"].read_typetree()["m_Name"] - ex.pack_sprite(ref, self.packer, "sprites" if "FixUiSpriteAtlas" in tex else f"sprite_{s['name']}") - textures = self.packer.build() - sprites = {ex.sprite_recs[ref]["name"]: ex.packed_sprite(ref) for ref in sorted(refs)} - for rec in self.nodes: - (rec.get("image") or {}).pop("spriteRef", None) - (rec.get("buttonImageState") or {}).pop("selectedRef", None) - materials = { - "Default UI Material": { - "material": "Default UI Material", - "shader": {"shader": "UI/Default"}, - "keywords": [], - "textures": {}, - "ints": {}, - "floats": {}, - "colors": {}, - "shaderDefaults": True, - } - } - for _, n in self.sources: - for c in n["components"]: - if c.get("class") in ("Image", "RawImage") and c.get("m_Material"): - materials.setdefault(c["m_Material"]["material"], json.loads(dumps(c["m_Material"]))) - for m in materials.values(): - storyfonts._material_textures(m) - needed = {} - for name in sorted({m["shader"]["shader"] for m in materials.values()}): - if name == "UI/Default": - needed[name] = self.player.shader("UI/Default", file="unity_builtin_extra") - elif name in ex.shaders: - needed[name] = ex.shaders[name] - else: - raise RuntimeError(f"shader {name} not in the loaded bundles") - index, summary = ex.dump_shaders(self.dir / "shaders", needed) - keywords = { - k: gles3_keywords(index, m["shader"]["shader"], m["keywords"], f"material {k}") - for k, m in materials.items() - } - dts = self.player.mono(self.player.resource("DOTweenSettings")) - doc = { - "about": about, - **(head or {}), - "roots": self.roots, - "nodes": self.nodes, - "sprites": sprites, - "textures": textures, - "materials": materials, - "materialKeywords": keywords, - "clips": clips, - "controllers": controllers, - "animatorsDropped": dropped, - "dotween": {k: dts[k] for k in DOTWEEN_FIELDS}, - "shaders": {"index": "shaders/shaders.json", "names": summary["names"]}, - } - if self.styles is not None: - doc["textStyle"] = self.styles.summary() - doc.update(tail or {}) - write_json(self.dir / "ui.json", doc) - self.doc = doc - return doc - - -# ---------------------------------------------------------------- home -def container_go(env, internal_id: str): - """The GameObject an AssetBundle container entry names (a prefab's root).""" - for o in env.objects: - if o.type.name == "AssetBundle": - for path, info in o.read_typetree()["m_Container"]: - if path.lower() == internal_id.lower(): - go = deref(o, info["asset"]) - if go is not None and go.type.name == "GameObject": - return go - raise KeyError(f"{internal_id}: no GameObject in the AssetBundle containers") - - -def _go_ref(owner, pptr: dict) -> int | None: - if not pptr["m_PathID"]: - return None - if pptr["m_FileID"] == 0: - return pptr["m_PathID"] - o = deref(owner, pptr) - return o.path_id if o is not None else None - - -def background(cat, key: str, situation_name: str, ex: Exporter) -> dict: - """The placed background prefab `key` as SpotSceneRoot.SetObject leaves it, over the nodes and materials of - room.extract_room (the same bundle load, glb_order): SpotBackground.Prepare(situation_name) on the prefab - root, the "floor" render queue (FindFirstChildWithName(background, "floor", "shadow"), its - GetComponentInChildren().material.renderQueue = 2000, before the root is activated), then - SetActiveFast(background, true). Objects outside the placed prefab (other prefabs of the bundle closure) are - not placed: inactive.""" - env = UnityPy.load(*[str(p) for p in cat.fetch_key(key)]) - graph = SceneGraph(env) - mf_by_go, mr_by_go = room.mesh_components(env) - rendered = {go for _, go in mr_by_go} # SceneGraph: a GameObject path id is in one file only - root_go = container_go(env, cat.entry(key)["internal_id"]).path_id - root = graph.tf_of_go[root_go] - spot_bg, volumes = [], [] - for o in env.objects: - if o.type.name != "MonoBehaviour": - continue - tf = graph.tf_of_go.get(o.read_typetree()["m_GameObject"]["m_PathID"]) - if tf is None or not under(graph, tf, root): - continue - cls = script_class(o) - if cls == "SpotBackground" and tf == root: # background.GetComponent() - spot_bg.append(o) - elif cls == "Volume": - volumes.append((o, tf)) - if spot_bg: - tt = spot_bg[0].read_typetree() - entries = [ - (e.get("SituationName"), [_go_ref(spot_bg[0], h) for h in e.get("HideObjects") or []]) - for e in tt.get("_situationObjects") or [] - ] - prep = prepare(graph, root, situation_name, entries) - else: - prep = { - "name": situation_name.replace(CLONE, ""), - "matched": False, - "activate": [], - "hide": [], - "overrides": {}, - } - overrides = prep["overrides"] - floor = find_first_child_with_name(graph, root, FLOOR_NAME, FLOOR_EXCLUDE) - renderer = ( - None - if floor is None - else component_in_children( - graph, floor, lambda t: _go(graph, t) in rendered, lambda t: active_in_hierarchy(graph, t, overrides) - ) - ) - shown = {**overrides, root_go: True} - nodes, mats = glb_order(room.drawn_meshes(mf_by_go, mr_by_go, graph, lambda p: None)) - rows = [] - for p in mats: - mat = p.deref() - tt = mat.read_typetree() - sh = referent(mat, tt["m_Shader"]) - rows.append((mat, tt, sh.read_typetree()["m_ParsedForm"]["m_Name"], sh)) - tex_index = glb_textures([(mat, tt, name) for mat, tt, name, _ in rows]) - if len(volumes) > 1: - raise NotImplementedError(f"{key}: {len(volumes)} Volume components") - volume = None - if volumes: - o, tf = volumes[0] - volume = { - "path": graph.path(tf), - "active": active_in_hierarchy(graph, tf, shown), - **volume_record(ex.component(o), graph.go[_go(graph, tf)]["m_Layer"]), - } - t = graph.tf[root] - return { - "roomRoot": { - "path": graph.path(root), - "localPosition": t["m_LocalPosition"], - "localRotation": t["m_LocalRotation"], - "localScale": t["m_LocalScale"], - }, - "roomNodes": [ - { - "path": graph.path(tf), - "placed": under(graph, tf, root), - "active": under(graph, tf, root) and active_in_hierarchy(graph, tf, shown), - "renderQueue": FLOOR_QUEUE if tf == renderer else None, - } - for tf, _ in nodes - ], - "roomMaterials": [room_material(mat, tt, name, tex_index) for mat, tt, name, _ in rows], - "situation": { - "name": prep["name"], - "matched": prep["matched"], - "activate": [graph.path(x) for x in prep["activate"]], - "hide": [graph.path(graph.tf_of_go[g]) if g in graph.tf_of_go else None for g in prep["hide"]], - }, - "floor": { - "object": graph.path(floor) if floor is not None else None, - "renderer": graph.path(renderer) if renderer is not None else None, - "renderQueue": FLOOR_QUEUE, - }, - "volume": volume, - "lights": [ - {"prefab": "background", **r} - for r in lighting_components(env, graph, root, lambda tf: active_in_hierarchy(graph, tf, shown)) - ], - "shaders": {name: sh for _, _, name, sh in reversed(rows)}, # the first material's object - "check": { - "nodes": [n for _, n in nodes], - "textures": len(tex_index), - "mirrored": sum( - 1 for tf, _ in nodes if under(graph, tf, root) and np.linalg.det(graph.world(tf)[:3, :3]) < 0 - ), - }, - } - - -def spine_records(env, ex: Exporter) -> tuple[dict, list, dict]: - """The Spine data of a situation prefab's bundle closure (loaded as spot.extract loads it): the material of - every atlas page (Exporter.material records by name), per SpotSpineCharacter in spot.json's order its - SkeletonAnimation's MeshRenderer sorting order / layer and {atlas page: material name} (SpineAtlasAsset - materials in page order), and the shaders the materials name. -> (materials, characters, {name: shader}).""" - graph = SceneGraph(env) - by_cls: dict[str | None, list] = {} - mr_by_go = {} - for o in env.objects: - if o.type.name == "MonoBehaviour": - by_cls.setdefault(script_class(o), []).append(o) - elif o.type.name == "MeshRenderer": - mr_by_go[o.read_typetree()["m_GameObject"]["m_PathID"]] = o - materials, shaders, pages_of = {}, {}, {} - - def pages(sda) -> dict: - if sda.path_id not in pages_of: - out = {} - for ap in sda.read_typetree()["atlasAssets"]: - a = deref(sda, ap) - if a is None: - continue - att = a.read_typetree() - names = spot._atlas_pages(spot._text_bytes(referent(a, att["atlasFile"]).read()).decode("utf-8")) - mats = [deref(a, m) for m in att["materials"]] - if len(names) != len(mats): - raise RuntimeError(f"atlas {att.get('m_Name')}: {len(names)} pages vs {len(mats)} materials") - for page, m in zip(names, mats): - rec = None if m is None else ex.material(m) - if rec is not None: - storyfonts._material_textures(rec) - materials.setdefault(rec["material"], rec) - sh = rec["shader"]["shader"] - shaders.setdefault(sh, ex.shaders[sh]) - out[page] = rec and rec["material"] - pages_of[sda.path_id] = out - return pages_of[sda.path_id] - - for o in by_cls.get("SkeletonDataAsset", []): - pages(o) - chars = [] - for o in by_cls.get("SpotSpineCharacter", []): - tt = o.read_typetree() - rec = { - "path": graph.path(graph.tf_of_go[tt["m_GameObject"]["m_PathID"]]), - "sortingOrder": None, - "sortingLayer": None, - "pageMaterials": {}, - } - anim = deref(o, tt["_animation"]) - if anim is not None: - at = anim.read_typetree() - mr = mr_by_go.get(at["m_GameObject"]["m_PathID"]) - if mr is not None: - mt = mr.read_typetree() - rec.update(sortingOrder=mt.get("m_SortingOrder"), sortingLayer=mt.get("m_SortingLayerID")) - sda = deref(anim, at["skeletonDataAsset"]) - rec["pageMaterials"] = dict(pages(sda)) if sda is not None else {} - chars.append(rec) - return {k: materials[k] for k in sorted(materials)}, chars, shaders - - -def render_settings(env) -> dict: - """renderSettings: the scene's RenderSettings (RENDER_SETTINGS_FIELDS; RENDER_SETTINGS_REFS as {"name"} or null) and - `lightmaps`, the lightmap count of its LightmapSettings. SpotManager.ActiveSpotScene makes the "Spot" scene the - active one, whose settings light the spot.""" - rs = [o for o in env.objects if o.type.name == "RenderSettings"] - lm = [o for o in env.objects if o.type.name == "LightmapSettings"] - if len(rs) != 1 or len(lm) != 1: - raise RuntimeError(f"scene: {len(rs)} RenderSettings and {len(lm)} LightmapSettings") - tt = rs[0].read_typetree() - out = {k: _plain(tt[k]) for k in RENDER_SETTINGS_FIELDS if k in tt} - for k in RENDER_SETTINGS_REFS: - ref = tt.get(k) - if not ref or not ref["m_PathID"]: - out[k] = None - continue - target = deref(rs[0], ref) - out[k] = {"name": target.read_typetree().get("m_Name") if target is not None else None} - out["lightmaps"] = len(lm[0].read_typetree().get("m_Lightmaps") or []) - return out - - -def lighting_components(env, graph, root_tf: int, active) -> list[dict]: - """The Light / ReflectionProbe components under `root_tf` in path order: path, type, m_Enabled, `active(tf)` - (activeInHierarchy as placed) and a Light's m_Type.""" - out = [] - for o in env.objects: - if o.type.name not in LIGHTING_TYPES: - continue - tt = o.read_typetree() - tf = graph.tf_of_go.get(tt["m_GameObject"]["m_PathID"]) - if tf is None or not under(graph, tf, root_tf): - continue - rec = { - "path": graph.path(tf), - "type": o.type.name, - "m_Enabled": int(tt.get("m_Enabled", 1)), - "active": bool(active(tf)), - } - if o.type.name == "Light": - rec["m_Type"] = tt.get("m_Type") - out.append(rec) - return sorted(out, key=lambda r: (r["path"], r["type"])) - - -def _active_path(nodes: list[dict], path: str) -> bool: - """activeInHierarchy of the node at `path` in a node list (it and its ancestors active).""" - return all(n["active"] for n in nodes if n["path"] == path or path.startswith(n["path"] + "/")) - - -def scene_root(ex: Exporter, key: str | None) -> dict: - """sceneRoot: the "Spot" scene's SpotSceneRoot: world TRS of `_objRoot` (the parent SetObject puts the - background and the situation under), its Light / ReflectionProbe components (with `active`, the object's - activeInHierarchy), its Volumes, the scene camera (`_sceneCamera`, advui camera record) and the scene's - RenderSettings (render_settings); null / [] when the scene or the component is not there.""" - out = {"key": key, "objRoot": None, "lights": [], "volumes": [], "sceneCamera": None, "renderSettings": None} - if key is None: - return out - nodes = ex.prefab(key)["nodes"] - out["renderSettings"] = render_settings(ex.load(key)[0]) - roots = [(n, c) for n in nodes for c in n["components"] if c.get("class") == "SpotSceneRoot"] - if len(roots) != 1: - return out - c = roots[0][1] - obj, cam = advui._ref_path(c.get("_objRoot")), advui._ref_path(c.get("_sceneCamera")) - out["objRoot"] = {"path": obj, **world_trs(nodes, obj)} if obj else None - out["lights"] = [ - { - "path": n["path"], - "type": x["type"], - "active": _active_path(nodes, n["path"]), - **{k: _plain(v) for k, v in x.items() if k != "type"}, - } - for n in nodes - for x in n["components"] - if x["type"] in LIGHTING_TYPES - ] - out["volumes"] = [ - {"path": n["path"], **volume_record(x, n["layer"])} - for n in nodes - for x in n["components"] - if x.get("class") == "Volume" - ] - out["sceneCamera"] = advui._camera(nodes, cam) if cam else None - return out - - -def home_host(cat, master_dir, player, host: dict, hdir: Path, keys: dict) -> dict: - """host.json `home`: spot.extract and room.extract_room into host/spot/, the placed background (background), - the Spine records, the Spot scene root, the blur, the ambient sound and the shaders (host/shaders/, GLES3 - programs only) with each material's GLES3 keyword set.""" - sdir = hdir / "spot" - doc = spot.extract(cat, Path(master_dir), host["spotId"], sdir) - row = doc["master"] - # A tap talk's character need not be among the spot's SpotCharacters (spot.json `characters`): the game then has - # no tap target that starts the talk (HomeSpotAfterTalkRequestFactory.TrySelectAdv matches the tapped - # SpotCharacter's _characterId), and the player plays it without the camera focus - bg_key, sit_key = row["_backgroundAssetPath"], row["_situationAssetPath"] - room_doc = room.extract_room(cat, bg_key, sdir / "room.glb") - ex = Exporter(cat, hdir, player=player, textures="deferred", inline_meshes=False) - sit_env = UnityPy.load(*[str(p) for p in cat.fetch_key(sit_key)]) - sit_go = container_go(sit_env, cat.entry(sit_key)["internal_id"]) - sit_name = sit_go.read_typetree()["m_Name"] - sit_graph = SceneGraph(sit_env) - sit_lights = [ - {"prefab": "situation", **r} - for r in lighting_components( - sit_env, - sit_graph, - sit_graph.tf_of_go[sit_go.path_id], - lambda tf: active_in_hierarchy(sit_graph, tf, {sit_go.path_id: True}), - ) - ] - bg = background(cat, bg_key, sit_name + CLONE, ex) - check_room(bg_key, glb_json(sdir / "room.glb"), room_doc, bg) - spine_mats, spine_chars, spine_shaders = spine_records(sit_env, ex) - if [c["path"] for c in spine_chars] != [c["path"] for c in doc["spineCharacters"]]: - raise RuntimeError(f"{sit_key}: Spine characters differ from spot.json's") - blur = advui.blur_record(player.graphics()) - blur_obj = [o for o in player.renderer_shaders(blur["renderer"]) if o.read().m_ParsedForm.m_Name == blur["shader"]] - if len(blur_obj) != 1: - raise RuntimeError(f"renderer {blur['renderer']}: {len(blur_obj)} shaders {blur['shader']}") - needed = dict(bg["shaders"]) - for name, o in [*spine_shaders.items(), (blur["shader"], blur_obj[0])]: - needed.setdefault(name, o) - index, summary = dump_gles3(needed, hdir / "shaders") - keywords = { - "room": [ - gles3_keywords(index, m["shader"], m["keywords"], f"room material {m['name']}") for m in bg["roomMaterials"] - ], - "spine": { - k: gles3_keywords(index, m["shader"]["shader"], m["keywords"], f"Spine material {k}") - for k, m in spine_mats.items() - }, - } - gles3_keywords(index, blur["shader"], [], "the blur pass") - return { - "spotId": host["spotId"], - "talk": host["talk"], - "characterId": host["characterId"], - "titleTextId": row.get("_advNameTextId") if host["talk"] == "area" else None, - "spot": f"{HOST_DIR}/spot/spot.json", - "room": f"{HOST_DIR}/spot/room.glb", - "roomRoot": bg["roomRoot"], - "roomNodes": bg["roomNodes"], - "roomMaterials": bg["roomMaterials"], - "situation": bg["situation"], - "floor": bg["floor"], - "sceneRoot": scene_root(ex, keys.get("spotScene")), - "volume": bg["volume"], - "lights": bg["lights"] + sit_lights, - "spineMaterials": spine_mats, - "spineCharacters": spine_chars, - "shaders": {"index": f"{HOST_DIR}/shaders/shaders.json", "names": summary["names"]}, - "materialKeywords": keywords, - "blur": blur, - "ambient": ambient_record(doc["situationSettings"], master_dir), - } - - -def home_ui(ui: UiDoc, keys: dict) -> tuple[str, dict, list]: - """host/ui nodes of a home talk: UISpotWidget's root, View, View/Fade, its DOTweenSequence_* nodes (and the - nodes their calls target), the SimpleAdv root (UISpotWidget._afterTalkContents) subtree; UISpotLetterbox. - The opened sequence is _fadeIn: the spot appears through SpotManager.PlayInAnimation -> SpotPresenter.FadeIn - (the Fade cover from 1 to 0); a spot change (ChangeSpotAsync) covers with _transitionIn and reveals with - _transitionOut, which ends with the cover at 0 as well. - -> (overlay root, layout root, opened sequences).""" - nodes = ui.prefab(keys["spotWidget"]) - w = advui._need_component(nodes[0], ("UISpotWidget",)) - overlay = advui._need_path(w["_afterTalkContents"]) - seqs = [advui._need_path(w[k]) for k in ("_transitionIn", "_transitionOut", "_fadeIn", "_fadeOut") if w.get(k)] - fade = f"{_parent(overlay)}/Fade" - ui.add(keys["spotWidget"], nodes, [overlay, fade, *seqs], _call_targets(nodes, seqs)) - ui.add(keys["spotLetterbox"], ui.prefab(keys["spotLetterbox"])) - return overlay, layout_root(nodes, overlay), [advui._need_path(w["_fadeIn"])] - - -def _call_targets(nodes: list[dict], seqs: list[str]) -> list[str]: - """The nodes the DOTweenSequence event entries of the nodes at `seqs` call.""" - out = [] - for n in nodes: - if n["path"] in seqs: - for c in n["components"]: - if c.get("class") == "DOTweenSequence": - for e in c["_list"]: - out += [x["target"] for x in tween_calls(e) if x["target"] and x["target"] not in out] - return out - - -def afterlive_ui(ui: UiDoc, keys: dict) -> tuple[str, dict, list, dict]: - """host/ui nodes of a live result talk: UILiveResultBackgroundWidget (its _backgroundImage showing the - live_result_background sprite); UILiveResultWidget's chain to the SimpleAdv root (_advSimpleContents) and its - subtree, the widget's and UISoloLiveMusicResultView's _inAnimation sequences and the nodes their calls target. - -> (overlay root, layout root, opened sequences, host.json `afterlive`).""" - bnodes = ui.prefab(keys["resultBackground"]) - bw = advui._need_component(bnodes[0], ("UILiveResultBackgroundWidget",)) - bg_path = advui._ref_path(bw["_backgroundImage"]) - recs = [r for r in ui.add(keys["resultBackground"], bnodes) if r["path"] == bg_path and r.get("image")] - if len(recs) != 1: - raise RuntimeError(f"{bg_path}: {len(recs)} image nodes") - sprite = ui.sprite(keys["resultBackgroundSprite"]) - recs[0]["image"].update(sprite=sprite["name"], spriteRef=sprite["spriteRef"]) - nodes = ui.prefab(keys["resultWidget"]) - rw = advui._need_component(nodes[0], ("UILiveResultWidget",)) - overlay = advui._need_path(rw["_advSimpleContents"]) - view = [ - (n, c) - for n in nodes - for c in n["components"] - if c.get("class") == "UISoloLiveMusicResultView" and overlay.startswith(n["path"] + "/") - ] - if len(view) != 1: - raise RuntimeError(f"{overlay}: {len(view)} UISoloLiveMusicResultView ancestors") - seqs = [ - advui._need_path(bw["_inAnimation"]), - advui._need_path(rw["_inAnimation"]), - advui._need_path(view[0][1]["_inAnimation"]), - ] - ui.add(keys["resultWidget"], nodes, [overlay, *seqs[1:]], _call_targets(nodes, seqs[1:])) - parts = overlay.split("/") - reward = next(("/".join(parts[: i + 1]) for i in range(len(parts) - 2, 0, -1) if parts[i] == REWARD_PANEL), None) - if reward is None: - raise RuntimeError(f"{overlay}: no {REWARD_PANEL} ancestor") - return ( - overlay, - layout_root(nodes, overlay), - seqs, - { - "backgroundNode": bg_path, - "backgroundSprite": sprite["name"], - "rewardPanel": reward, - "substitutes": list(SUBSTITUTES), - }, - ) - - -# ---------------------------------------------------------------- background (SpotBackground, SpotSceneRoot) -def _go(graph, tf: int) -> int: - return graph.tf[tf]["m_GameObject"]["m_PathID"] - - -def _children(graph, tf: int) -> list[int]: - return [c["m_PathID"] for c in graph.tf[tf]["m_Children"]] - - -def prepare(graph, background_tf: int, situation_name: str, entries: list) -> dict: - """SpotBackground.Prepare(situationName) on the background at `background_tf` (entries: its _situationObjects - as [(SituationName, [HideObjects GameObject path ids | None])]): "(Clone)" removed from the name; the first - entry of that name activates every child of transform.GetChild(0) and deactivates its HideObjects; no entry of - that name keeps the prefab state. -> {name, matched, activate [transform ids], hide [GameObject ids | None], - overrides {GameObject id: activeSelf}}.""" - name = situation_name.replace(CLONE, "") - kids = _children(graph, background_tf) - if not kids: - raise RuntimeError(f"{graph.path(background_tf)}: SpotBackground without a child (GetChild(0))") - for entry_name, hide in entries: - if entry_name == name: - activate = _children(graph, kids[0]) - overrides = {_go(graph, t): True for t in activate} - overrides.update({g: False for g in hide if g}) - return {"name": name, "matched": True, "activate": activate, "hide": list(hide), "overrides": overrides} - return {"name": name, "matched": False, "activate": [], "hide": [], "overrides": {}} - - -def active_in_hierarchy(graph, tf: int, overrides: dict) -> bool: - """GameObject.activeInHierarchy of the object at `tf`, with `overrides` {GameObject id: activeSelf} over the - serialized m_IsActive.""" - while tf in graph.tf: - go = _go(graph, tf) - if not overrides.get(go, bool(graph.go.get(go, {}).get("m_IsActive", 1))): - return False - tf = graph.tf[tf]["m_Father"]["m_PathID"] - return True - - -def under(graph, tf: int, root: int) -> bool: - """The transform `tf` is `root` or below it.""" - while tf in graph.tf: - if tf == root: - return True - tf = graph.tf[tf]["m_Father"]["m_PathID"] - return False - - -def find_first_child_with_name(graph, tf: int, contains: str, excludes: str) -> int | None: - """SpotSceneRoot.SetObject's FindFirstChildWithName: depth first over the descendants of `tf` (itself - excluded), children in order, the first whose name contains `contains` and not `excludes` (ordinal).""" - for c in _children(graph, tf): - name = graph.go.get(_go(graph, c), {}).get("m_Name", "") - if contains in name and excludes not in name: - return c - hit = find_first_child_with_name(graph, c, contains, excludes) - if hit is not None: - return hit - return None - - -def component_in_children(graph, tf: int, has, active) -> int | None: - """GameObject.GetComponentInChildren() (inactive objects skipped): depth first from `tf` itself, the first - transform whose object is active in the hierarchy (`active(tf)`) and has the component (`has(tf)`).""" - if active(tf) and has(tf): - return tf - for c in _children(graph, tf): - hit = component_in_children(graph, c, has, active) - if hit is not None: - return hit - return None - - -def glb_order(drawn, arrays_of=mesh_arrays) -> tuple[list, list]: - """The nodes and materials room.extract_room writes, in its order, from its room.drawn_meshes items - [(transform id, Mesh, material PPtrs)]: a mesh without vertex data or triangles, or whose submeshes all lack - a material, is no node; a material is numbered at its first use (room.submesh_primitives order, by - room.object_key). -> ([(transform id, mesh name)], [material PPtr]).""" - index: dict[tuple[str, int], int] = {} - mats, nodes = [], [] - - def material_for(p) -> int: - key = room.object_key(p.deref()) - if key not in index: - index[key] = len(mats) - mats.append(p) - return index[key] - - for tf, mesh, pptrs in drawn: - arrays = arrays_of(mesh) - if arrays is None: - continue - tris = arrays[3] - if not any(len(t) for t in tris): - continue - if len(pptrs) < len(tris): - raise NotImplementedError(f"{mesh.m_Name}: {len(tris)} submeshes, {len(pptrs)} materials") - if room.submesh_primitives(tris, pptrs, material_for, lambda i: None): - nodes.append((tf, mesh.m_Name)) - return nodes, mats - - -def glb_textures(materials: list) -> dict[tuple[str, int], int]: - """{texture room.object_key: glb texture index} of room.extract_room for its materials [(material object, - typetree, shader name)] in glb order: the main texture slot of each (room.main_textures), numbered at first - use.""" - index: dict[tuple[str, int], int] = {} - for owner, tt, shader_name in materials: - for tex, _ in room.main_textures(owner, tt, shader_name): - index.setdefault(room.object_key(tex), len(index)) - return index - - -def _texture_key(owner, ref: dict) -> tuple[str, int] | None: - """room.object_key of the texture a material's PPtr `ref` names (read from `owner`'s typetree); None for an - empty slot or a texture outside the loaded files (so not in the glb).""" - if not ref["m_PathID"]: - return None - try: - return room.object_key(deref(owner, ref)) - except FileNotFoundError: - return None - - -def room_material(owner, tt: dict, shader_name: str, tex_index: dict) -> dict: - """A room material (`owner` its object, `tt` its typetree) as saved (its shader runs as the game's): name, - shader, keywords, float / int / colour properties, custom render queue, and per texture slot the glb texture - index (null when the texture is not in the glb), scale and offset.""" - sp = tt["m_SavedProperties"] - keywords = tt.get("m_ValidKeywords") - if keywords is None: - keywords = (tt.get("m_ShaderKeywords") or "").split() - return { - "name": tt["m_Name"], - "shader": shader_name, - "keywords": list(keywords), - "floats": dict(sp.get("m_Floats", [])), - "ints": dict(sp.get("m_Ints", [])), - "colors": dict(sp.get("m_Colors", [])), - "renderQueue": tt.get("m_CustomRenderQueue"), - "texEnvs": { - k: { - "texture": tex_index.get(_texture_key(owner, v["m_Texture"])), - "scale": [v["m_Scale"]["x"], v["m_Scale"]["y"]], - "offset": [v["m_Offset"]["x"], v["m_Offset"]["y"]], - } - for k, v in sp["m_TexEnvs"] - }, - } - - -def glb_json(path) -> dict: - """The JSON chunk of a binary glTF file.""" - data = Path(path).read_bytes() - (n,) = struct.unpack_from(" None: - """room.extract_room's room.glb (its JSON chunk `glb`) and room.json (`room_doc`) have the nodes, materials and - glTF textures of the background record `bg` (background), in its order.""" - if ( - [n["name"] for n in glb["nodes"]] != bg["check"]["nodes"] - or room_doc["meshCount"] != len(bg["roomNodes"]) - or [m["name"] for m in glb.get("materials", [])] != [m["name"] for m in bg["roomMaterials"]] - or len(glb.get("textures", [])) != bg["check"]["textures"] - ): - raise RuntimeError(f"{key}: room.glb nodes / materials / textures differ from the background's order") - - -def volume_record(comp: dict, layer: int) -> dict: - """A Volume component (Exporter record: sharedProfile and its components inlined) -> enabled, isGlobal, - weight, priority, blendDistance, layer, profile name and per profile component its class, active flag and the - parameters it overrides (m_OverrideState set) with their values.""" - prof = comp.get("sharedProfile") or {} - comps = [] - for c in prof.get("components") or []: - if not c: - continue - over = { - k: _plain(v["m_Value"]) - for k, v in c.items() - if isinstance(v, dict) and "m_OverrideState" in v and v["m_OverrideState"] - } - comps.append({"class": c.get("asset"), "active": bool(c.get("active", 1)), **over}) - return { - "enabled": bool(comp.get("m_Enabled", 1)), - "isGlobal": bool(comp["m_IsGlobal"]), - "weight": comp["weight"], - "priority": comp["priority"], - "blendDistance": comp["blendDistance"], - "layer": layer, - "profile": prof.get("name"), - "components": comps, - } - - -def _qmul(a, b): - ax, ay, az, aw = a - bx, by, bz, bw = b - return ( - aw * bx + ax * bw + ay * bz - az * by, - aw * by - ax * bz + ay * bw + az * bx, - aw * bz + ax * by - ay * bx + az * bw, - aw * bw - ax * bx - ay * by - az * bz, - ) - - -def _rotate(q, v): - x, y, z, _ = _qmul(_qmul(q, (v[0], v[1], v[2], 0.0)), (-q[0], -q[1], -q[2], q[3])) - return (x, y, z) - - -def world_trs(nodes: list[dict], path: str) -> dict: - """World position, rotation and (lossy) scale of the node at `path` from the local TRS of it and its - ancestors (position through the parents' rotation and scale, rotation the product of the local rotations, - scale the product of the local scales).""" - parts = path.split("/") - pos, rot, scl = (0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0), (1.0, 1.0, 1.0) - for i in range(1, len(parts) + 1): - n = _first(nodes, "/".join(parts[:i])) - p, r, s = n["localPosition"], n["localRotation"], n["localScale"] - d = _rotate(rot, (scl[0] * p["x"], scl[1] * p["y"], scl[2] * p["z"])) - pos = (pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]) - rot = _qmul(rot, (r["x"], r["y"], r["z"], r["w"])) - scl = (scl[0] * s["x"], scl[1] * s["y"], scl[2] * s["z"]) - return {"position": dict(zip("xyz", pos)), "rotation": dict(zip("xyzw", rot)), "scale": dict(zip("xyz", scl))} - - -def _rows(master_dir, name: str) -> dict: - try: - return {r["_id"]: r for r in master.table(Path(master_dir), name)} - except FileNotFoundError: - return {} - - -def ambient_record(settings: dict, master_dir) -> dict | None: - """ambient: the situation's ambient sound (SpotSituationSettings._soundId, ambientSoundVolume) with its cue - sheet and cue (MasterSound, MasterSoundCueSheet; null when the master data lacks the row); null without one.""" - sid = settings.get("_soundId") or 0 - if not sid: - return None - snd = _rows(master_dir, "MasterSound").get(sid) or {} - sheet = _rows(master_dir, "MasterSoundCueSheet").get(snd.get("_soundCueSheetID")) or {} - return { - "soundId": sid, - "volume": settings.get("ambientSoundVolume"), - "cueSheet": sheet.get("_cueSheetName"), - "cue": snd.get("_cueName"), - } - - -# ---------------------------------------------------------------- per language: ui/simple/ -def text_bindings(ui: UiDoc, lang: dict, mode: int) -> dict[str, dict]: - """{node path: text binding} of the text nodes of `ui`, as storyfonts.text_bindings makes them for the story - UI: the serialized TextMeshPro fields, storyfonts.text_extras and `localized` (LocalizeText in `mode`).""" - swap = textstyle.font_swap(lang, mode) - out = {} - for rec, (_, n) in zip(ui.nodes, ui.sources): - if "textStyle" not in rec: - continue - path = rec["path"] - if path in out: - raise RuntimeError(f"{path}: two text nodes") - c = advui._need_component(n, advui.TMP_CLASSES) - t = {k: c[k] for k in advui.TMP_FIELDS if k in c} - t.update( - { - "class": c["class"], - "enabled": c["m_Enabled"], - "fontAsset": c["m_fontAsset"]["name"], - "material": c["m_sharedMaterial"]["material"], - } - ) - t.update(storyfonts.text_extras(path, n["components"])) - t["localized"] = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode) - out[path] = t - return out - - -def story_text_materials(story_fonts: dict | None, lang: dict, mode: int) -> dict[str, set]: - """{localized game font asset: its localized game text materials} of the story UI's texts (the `texts` of - the story's ui/fonts.json, open fonts).""" - if not story_fonts or story_fonts.get("source") != "open": - return {} - swap, out = textstyle.font_swap(lang, mode), {} - for path, t in story_fonts.get("texts", {}).items(): - loc = textstyle.localize_text(path, t["fontAsset"], t["material"], swap, lang, mode) - out.setdefault(loc["fontAsset"], set()).add(loc["material"]) - return out - - -def open_fonts( - ui: UiDoc, - cat, - player, - episode: dict, - story_ui: dict, - language: str, - font, - master_dir, - texts: list[str], - story_fonts: dict | None = None, - font_of=storyfonts.no_font_file, -) -> dict: - """ui/simple/fonts.json and ui/simple/fonts/*.png from the font file `font` for the text nodes of `ui`, with the - story UI's font logic (storyfonts.open_font_set: the fallback chains, whose other font files `font_of` gives, - the missing glyph, the fallback materials): the texts are storyfonts.shown_texts over the story and its own - ui/ui.json (`story_ui`), the MasterText rows of the simple UI's text keys and `texts`. The glyph margin also - covers the story UI's text materials of the same font (`story_fonts`, the story's ui/fonts.json), so the glyph - pages equal the story UI's when the characters do.""" - tmpfont.require_extra() - mode, field = languages.mode(language), languages.column(language)[1:] - lang = textstyle.language_fonts(player) - bindings = text_bindings(ui, lang, mode) - if ui.doc is None: - raise RuntimeError("the simple UI's texts are read from its written ui.json") - shown = ( - storyfonts.shown_texts(episode, story_ui, master_dir, field) - + storyfonts.shown_texts(NO_EPISODE, ui.doc, master_dir, field) - + [s for s in texts if isinstance(s, str)] - ) - fset = storyfonts.open_font_set( - cat, - player, - ui.ex, - language, - font, - list(bindings.values()), - shown, - ui.dir, - font_of, - margin_materials=story_text_materials(story_fonts, lang, mode), - ) - materials = fset["materials"] - keywords = storyfonts.text_shaders(ui.ex, ui.dir, materials) - chars = {ord(ch) for s in shown for ch in s} - coverage = {"characters": len(chars), "missing": [chr(u) for u in sorted(fset["missing"])]} - if fset["substituted"]: - coverage["missingGlyph"] = [chr(u) for u in sorted(fset["substituted"])] - doc = { - "format": storyfonts.FONTS_FORMAT, - "language": language, - "source": "open", - "fonts": fset["fonts"], - "textures": fset["textures"], - "materials": materials, - "materialKeywords": keywords, - "texts": bindings, - "coverage": coverage, - "lineBreaking": storyfonts.line_breaking(player), - } - (ui.dir / storyfonts.FONTS_DOC).write_bytes( - dumps(doc, ensure_ascii=False, indent=1, sort_keys=True).encode("utf-8") + b"\n" - ) - return { - "fonts": sorted(fset["fonts"]), - "characters": len(chars), - "missing": len(fset["missing"]), - "pages": len(fset["textures"]), - } - - -def simple_ui( - cat, - master_dir, - player, - episode: dict, - ldir: Path, - language: str, - keys: dict, - host: dict, - title_id: str | None, - font, - font_of=storyfonts.no_font_file, -) -> dict: - """/ui/simple/: ui.json (UISimpleAdvTalkWindow, the window SimpleAdvPlayer.LoadInitialTalkWindow - attaches; for an area talk UISystemMessageWidget, which SystemMessageManager.ShowMessageByTextIdAsync shows the - spot's title with, and `texts.areaTitle`) and its open fonts.""" - sdir = Path(ldir) / "ui" / SIMPLE_DIR - story_ui = json.loads((Path(ldir) / "ui" / "ui.json").read_text(encoding="utf-8")) - fonts_doc = Path(ldir) / "ui" / storyfonts.FONTS_DOC - story_fonts = json.loads(fonts_doc.read_text(encoding="utf-8")) if fonts_doc.is_file() else None - ui = UiDoc(cat, player, sdir, languages.mode(language)) - ui.add(keys["talkWindow"], ui.prefab(keys["talkWindow"])) - texts = {} - if host.get("talk") == "area": - ui.add(keys["systemMessage"], ui.prefab(keys["systemMessage"])) - field = languages.column(language) - row = _rows(master_dir, "MasterText").get(title_id) - if row is None: - raise KeyError(f"area talk title {title_id} not in MasterText") - texts["areaTitle"] = row.get(field) or "" - ui.write( - f"SimpleAdvPlayer talk window UI data, {language}", - head={"language": advui.language_doc(language)}, - tail={"texts": texts} if texts else None, - ) - return open_fonts( - ui, cat, player, episode, story_ui, language, font, master_dir, list(texts.values()), story_fonts, font_of - ) - - -# ---------------------------------------------------------------- entry -def build( - cat, - master_dir, - player, - episode: dict, - story_dir, - language_dirs: dict, - groups: list, - *, - fonts: str = "open", - font_files: dict | None = None, - font_of=None, -) -> dict | None: - """The host data of `episode` (episode.json of story.build) when it is an Overlay episode (master - `_playbackMode` 1; else None and nothing written): /host/ (host.json, ui/, spot/, shaders/) and in - each language directory of `language_dirs` {language: dir with the story's ui/ui.json} its ui/simple/. - `groups`: the story groups of the episode (storysite.story_groups), which name the host (host_of); `fonts`: - "open" with `font_files` {language: storyfonts.FontFile} and `font_of` (language -> FontFile, the fallback - chains' other font files; default: those of `font_files`). -> {kind, doc, ui} (the story manifest's `host`).""" - if fonts not in FONT_MODES: - raise ValueError(f"fonts must be one of {FONT_MODES}, not {fonts!r}") - if (episode.get("master") or {}).get("_playbackMode") != OVERLAY: - return None - host = host_of(groups) - if fonts == "game": - raise NotImplementedError("the talk window UI of an Overlay story has open fonts only (fonts='open')") - font_files = dict(font_files or {}) - lost = [lang for lang in language_dirs if lang not in font_files] - if lost: - raise ValueError(f"no font file for {lost}") - - def own_font(code): - return font_files[code] if code in font_files else storyfonts.no_font_file(code) - - font_of = font_of or own_font - keys: dict = {k: emb_key(cat, ADDRESSES[k]) for k in ("cameraTarget", "talkWindow")} - if host.get("talk") == "area": - keys["systemMessage"] = emb_key(cat, ADDRESSES["systemMessage"]) - for k in KIND_KEYS[host["kind"]]: - try: - keys[k] = emb_key(cat, ADDRESSES[k]) - except KeyError: - if k != "spotScene": - raise - keys[k] = None # scene root: null / [] (scene_root) - hdir = Path(story_dir) / HOST_DIR - shutil.rmtree(hdir, ignore_errors=True) - ui = UiDoc(cat, player, hdir / "ui") - cam = camera_target(ui.prefab(keys["cameraTarget"])) - home = afterlive = None - if host["kind"] == "home": - overlay, layout, opened = home_ui(ui, keys) - home = home_host(cat, master_dir, player, host, hdir, keys) - else: - overlay, layout, opened, afterlive = afterlive_ui(ui, keys) - ui.write(f"host screen UI data of an Overlay story ({host['kind']}), no texts") - doc = { - "format": FORMAT, - "about": "Host screen of an Overlay story (SimpleAdvPlayer): the slot capture camera, the SimpleAdv " - "root and its layout, the host UI and the home spot or live result screen it is shown over", - "advId": episode.get("advId"), - "kind": host["kind"], - "group": host["group"], - "keys": keys, - "cameraTarget": cam, - "overlayRoot": overlay, - "layoutRoot": layout, - "openedSequences": opened, - "ui": HOST_UI_DOC, - "home": home, - "afterlive": afterlive, - } - write_json(hdir / "host.json", doc) - for language, ldir in language_dirs.items(): - shutil.rmtree(Path(ldir) / "ui" / SIMPLE_DIR, ignore_errors=True) - simple_ui( - cat, - master_dir, - player, - episode, - Path(ldir), - language, - keys, - host, - (home or {}).get("titleTextId"), - font_files[language], - font_of, - ) - return {"kind": host["kind"], "doc": HOST_DOC, "ui": SIMPLE_DOC} diff --git a/src/nnnotes/content/storyhostproduct.py b/src/nnnotes/content/storyhostproduct.py new file mode 100644 index 0000000..987f7e8 --- /dev/null +++ b/src/nnnotes/content/storyhostproduct.py @@ -0,0 +1,285 @@ +"""Host-screen objects and initial state for Overlay stories.""" + +from __future__ import annotations + +from ..source import languages +from . import storyui +from .livenotes import animator_bindings +from .prefabs import ( + Prefab, + builtin_root, + component_class, + component_ref, + has_json, + key_object, + mesh_roles, + ref_id, + shader_table, +) + +FORMAT = "nnnotes.story-host/1" +OVERLAY = 1 +TABLES = ("MasterHomeSpot", "MasterStoryHomeSpotTapTalkEpisode", "MasterStoryLiveResultEpisode", "MasterText") +ADDRESSES = { + "cameraTarget": "Common/Prefab/CameraTargetRenderer", + "spotWidget": "UI/Prefab/UISpotWidget", + "spotLetterbox": "UI/Prefab/Parts/Spot/UISpotLetterbox", + "spotScene": "Scene/Spot", + "resultWidget": "UI/Prefab/UILiveResultWidget", + "resultBackground": "UI/Prefab/UILiveResultBackgroundWidget", + "resultBackgroundSprite": "LowImage/LiveResult/live_result_background", +} +KIND_KEYS = { + "home": ("spotWidget", "spotLetterbox", "spotScene"), + "afterlive": ("resultWidget", "resultBackground", "resultBackgroundSprite"), +} +SUBSTITUTES = ("musicResultPanel", "rewardItems", "memberCard", "navigation", "gradeUpEffect") + + +def episode_host(tables: dict, episode: dict) -> dict | None: + """The host selected by an Overlay episode's unique home or live-result master row.""" + if episode.get("_playbackMode") != OVERLAY: + return None + adv_id = episode["_id"] + hits = [] + for row in tables["MasterStoryHomeSpotTapTalkEpisode"]: + if row["_advId"] == adv_id: + hits.append( + { + "subject": f"home/{row['_spotId']}", + "kind": "home", + "talk": "tap", + "spotId": row["_spotId"], + "characterId": row.get("_characterId") or None, + "group": {"kind": "spotTalk", "id": row["_id"]}, + } + ) + for row in tables["MasterHomeSpot"]: + if row.get("_advId") == adv_id: + texts = [r for r in tables["MasterText"] if r["_id"] == row.get("_advNameTextId")] + if not texts: + raise ValueError(f"home spot {row['_id']}: area title is absent from MasterText") + hits.append( + { + "subject": f"home/{row['_id']}", + "kind": "home", + "talk": "area", + "spotId": row["_id"], + "characterId": None, + "titleTextId": row["_advNameTextId"], + "title": languages.texts(texts[0]), + "group": {"kind": "spot", "id": row["_id"]}, + } + ) + for row in tables["MasterStoryLiveResultEpisode"]: + if row["_advId"] == adv_id: + hits.append( + {"subject": "afterlive", "kind": "afterlive", "group": {"kind": "liveResult", "id": row["_id"]}} + ) + if len(hits) != 1: + raise ValueError(f"Overlay episode {adv_id}: expected one host, found {len(hits)}") + host = hits[0] + if host["kind"] == "home": + row = record(tables, host["subject"])["spot"] + host.update(spotKey=row["_situationAssetPath"], roomKey=row["_backgroundAssetPath"]) + return host + + +def parse_subject(subject: str) -> tuple[str, int | None]: + if subject == "afterlive": + return subject, None + kind, sep, number = subject.partition("/") + if kind != "home" or not sep or not number.isdigit() or str(int(number)) != number: + raise ValueError(f"{subject!r}: expected afterlive or home/") + return kind, int(number) + + +def record(tables: dict, subject: str) -> dict: + kind, spot_id = parse_subject(subject) + if kind == "afterlive": + return {"kind": kind} + rows = [r for r in tables["MasterHomeSpot"] if r["_id"] == spot_id] + if len(rows) != 1: + raise ValueError(f"home spot {spot_id}: expected one MasterHomeSpot row") + return {"kind": kind, "spot": rows[0]} + + +def addresses(record: dict, catalog) -> dict[str, str]: + held, out = set(catalog), {} + for name in ("cameraTarget", *KIND_KEYS[record["kind"]]): + address = ADDRESSES[name] + key = next((k for k in (address, f"Emb{address}") if k in held), None) + if key is None: + if name == "spotScene": + continue + raise ValueError(f"{address}: no plain or embedded asset in the catalog") + out[name] = key + if record["kind"] == "home": + out.update(background=record["spot"]["_backgroundAssetPath"], situation=record["spot"]["_situationAssetPath"]) + return out + + +def _component(objects, prefab: str, cls: str, *, path: str | None = None): + hits = [ + (i, c) + for i, n in enumerate(objects.json(prefab)["nodes"]) + for c in n["components"] + if (path is None or n["path"] == path) and component_class(c) == cls + ] + if len(hits) != 1: + raise ValueError(f"{prefab}: expected one {cls} at {path!r}, found {len(hits)}") + return hits[0] + + +def _target(objects, prefab: str, ref) -> dict: + if ref_id(ref) is not None: + hit = component_ref(objects, prefab, ref) + if hit is None: + raise ValueError("host component target is null") + holder, index, _ = hit + else: + holder = prefab + index = Prefab(objects.json(prefab)).node_of(ref) + if index is None: + raise ValueError("host node target is null") + return {"prefab": holder, "path": objects.json(holder)["nodes"][index]["path"]} + + +def background_state(doc: dict, situation_name: str) -> dict: + """Background activation and the floor renderer's queue after the host places a situation.""" + prefab = Prefab(doc) + root = prefab.nodes[0] + overrides, activated, hidden = {}, [], [] + name = situation_name.replace("(Clone)", "") + matches = [c for c in root["components"] if component_class(c) == "SpotBackground"] + matched = False + if matches: + children = [i for i, p in enumerate(prefab.parent) if p == 0] + if not children: + raise ValueError("SpotBackground needs a child container") + for entry in matches[0]["fields"].get("_situationObjects") or []: + if entry.get("SituationName") == name: + matched = True + activated = [i for i, p in enumerate(prefab.parent) if p == children[0]] + hidden = [prefab.node_of(r) for r in entry.get("HideObjects") or []] + overrides.update({i: True for i in activated}) + overrides.update({i: False for i in hidden if i is not None}) + break + + def active(index): + while index is not None: + if not overrides.get(index, prefab.nodes[index].get("active", True)): + return False + index = prefab.parent[index] + return True + + floor = next( + (i for i, n in enumerate(prefab.nodes) if i and "floor" in n["name"] and "shadow" not in n["name"]), None + ) + renderer = None + if floor is not None: + path = prefab.nodes[floor]["path"] + renderer = next( + ( + i + for i, n in enumerate(prefab.nodes) + if (n["path"] == path or n["path"].startswith(path + "/")) + and active(i) + and any(component_class(c) == "MeshRenderer" for c in n["components"]) + ), + None, + ) + overrides[0] = True + return { + "situation": { + "name": name, + "matched": matched, + "activate": [prefab.nodes[i]["path"] for i in activated], + "hide": [None if i is None else prefab.nodes[i]["path"] for i in hidden], + }, + "nodes": { + n["path"]: {"active": active(i), "renderQueue": 2000 if i == renderer else None} + for i, n in enumerate(prefab.nodes) + }, + "floor": { + "object": None if floor is None else prefab.nodes[floor]["path"], + "renderer": None if renderer is None else prefab.nodes[renderer]["path"], + "renderQueue": 2000, + }, + } + + +def host(objects, record: dict, parts: dict) -> tuple[dict, set, dict]: + """host.json, retained object ids and graphics-API roles from object data.""" + keys = addresses(record, objects.keys()) + roots = { + name: key_object(objects, key, "GameObject") + for name, key in keys.items() + if name not in ("resultBackgroundSprite", "spotScene") + } + kind = record["kind"] + ui_name, field = ("spotWidget", "_afterTalkContents") if kind == "home" else ("resultWidget", "_advSimpleContents") + prefab = roots[ui_name] + cls = "UISpotWidget" if kind == "home" else "UILiveResultWidget" + _, widget = _component(objects, prefab, cls) + overlay = _target(objects, prefab, widget["fields"][field]) + _, layout = _component(objects, overlay["prefab"], "SimpleAdvLayoutRoot", path=overlay["path"]) + opened = [_target(objects, prefab, widget["fields"]["_fadeIn" if kind == "home" else "_inAnimation"])] + home, afterlive = None, None + if kind == "home": + scene = keys.get("spotScene") + scene_roots = [oid for oid in objects.key(scene) if oid in objects and has_json(objects, oid)] if scene else [] + home = { + "spotId": record["spot"]["_id"], + "background": roots["background"], + "situation": roots["situation"], + "scene": scene_roots, + "parts": parts, + "backgroundState": background_state( + objects.json(roots["background"]), objects.json(roots["situation"])["name"] + ), + } + else: + background = roots["resultBackground"] + _, bg = _component(objects, background, "UILiveResultBackgroundWidget") + opened.insert(0, _target(objects, background, bg["fields"]["_inAnimation"])) + ancestors = [ + (i, c) + for i, n in enumerate(objects.json(prefab)["nodes"]) + for c in n["components"] + if component_class(c) == "UISoloLiveMusicResultView" and overlay["path"].startswith(n["path"] + "/") + ] + if len(ancestors) != 1: + raise ValueError("live result overlay needs one UISoloLiveMusicResultView ancestor") + opened.append(_target(objects, prefab, ancestors[0][1]["fields"]["_inAnimation"])) + path = overlay["path"].split("/") + reward = next(("/".join(path[: i + 1]) for i in range(len(path) - 2, 0, -1) if path[i] == "RewardPanel"), None) + if reward is None: + raise ValueError("live result overlay needs a RewardPanel ancestor") + afterlive = { + "backgroundImage": _target(objects, background, bg["fields"]["_backgroundImage"]), + "backgroundSprite": key_object(objects, keys["resultBackgroundSprite"], "Sprite"), + "rewardPanel": {"prefab": prefab, "path": reward}, + "substitutes": list(SUBSTITUTES), + } + graphics = builtin_root(objects, "GraphicsSettings") + included = [ref_id(r) for r in objects.json(graphics).get("m_AlwaysIncludedShaders", ())] + shaders, roles = shader_table(objects, found=[oid for oid in included if oid is not None]) + keep = {oid for oid in objects.ids() if objects.info(oid).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + doc = { + "format": FORMAT, + "kind": kind, + "prefabs": roots, + "cameraTarget": roots["cameraTarget"], + "overlayRoot": overlay, + "layoutRoot": {**overlay, "fields": layout["fields"]}, + "openedSequences": opened, + "home": home, + "afterlive": afterlive, + "graphics": {"graphics": graphics}, + "dotween": storyui.script_root(objects, "DOTweenSettings"), + "bindings": animator_bindings(objects), + "shaders": shaders, + } + return doc, keep, roles diff --git a/src/nnnotes/content/storyscene.py b/src/nnnotes/content/storyscene.py new file mode 100644 index 0000000..b8607c6 --- /dev/null +++ b/src/nnnotes/content/storyscene.py @@ -0,0 +1,70 @@ +"""The story scene of a product: what the ADV story screen loads before its first line. + +One product (`scene`, scene.json format SCENE_FORMAT), read from a product's object table (`Objects`, +pipeline.assemble), the way livescene reads the live scene: + + scene the root GameObjects of the addressable scene EmbScene/Adv that have their prefab document + (the ADV scene's roots and their references to each other, prefabs.component_ref) + renderSettings the scene's RenderSettings + prefabs the camera manager, the character and background fields and the global volume (PREFAB_KEYS) + settings the ADV player and master id settings, by script class (SETTINGS_KEYS) + graphics the built-in settings the render path reads (livescene.GRAPHICS) + bindings {prefab GameObject id: {Animator node path: {clip id: [{path, attribute}]}}}: the clips each + Animator plays through its controller (livenotes.animator_bindings) + shaders {shader name: object id}: what a lookup by name finds (prefabs.shader_table; of different + shaders of one name the one GraphicsSettings always includes) + +Of the objects the product keeps all but the MonoScripts; of a shader its parsed form, variant index and the programs +of the player's graphics API; of a Mesh its typetree JSON and its `arrays` document (prefabs.mesh_roles). +""" + +from __future__ import annotations + +from .livenotes import animator_bindings +from .livescene import GRAPHICS +from .prefabs import Objects, builtin_root, has_json, key_object, mesh_roles, ref_id, shader_table + +SCENE_FORMAT = 1 # scene.json "format" +SCENE_KEY = "EmbScene/Adv" +PREFAB_KEYS = { + "cameraManager": "EmbCommon/Prefab/CameraManager", + "characterField": "EmbCommon/Adv/Prefab/AdvCharacterField", + "backgroundField": "EmbCommon/Adv/Prefab/AdvBackgroundField", + "globalVolume": "EmbCommon/Adv/Prefab/AdvGlobalVolume", +} +# the settings of `settings`: {name: (catalog key, script class)} +SETTINGS_KEYS = { + "playerSettings": ("EmbCommon/Adv/Settings/AdvPlayerSettings", "AdvPlayerSettings"), + "masterIdSettings": ("EmbCommon/Adv/Settings/AdvMasterIdSettings", "AdvMasterIdSettings"), +} + + +def keys() -> list[str]: + """The catalog keys of the story scene: the scene key, the prefab keys and the settings keys, sorted.""" + return sorted([SCENE_KEY, *PREFAB_KEYS.values(), *(key for key, _ in SETTINGS_KEYS.values())]) + + +def scene(objects: Objects) -> tuple[dict, set[str], dict[str, set[str]]]: + """(scene.json, the object ids the player reads, {object id: the roles of its files it reads}) of the story scene + product (module documentation).""" + roots = [o for o in objects.key(SCENE_KEY) if o in objects] + gos = [o for o in roots if objects.info(o).get("class") == "GameObject" and has_json(objects, o)] + render = [o for o in roots if objects.info(o).get("class") == "RenderSettings"] + if len(render) != 1: + raise ValueError(f"{SCENE_KEY}: {len(render)} RenderSettings") + graphics = {name: builtin_root(objects, cls) for name, cls in GRAPHICS.items()} + always = (ref_id(r) for r in objects.json(graphics["graphics"]).get("m_AlwaysIncludedShaders", ())) + shaders, roles = shader_table(objects, found=[o for o in always if o is not None]) + doc = { + "format": SCENE_FORMAT, + "scene": gos, + "renderSettings": render[0], + "prefabs": {name: key_object(objects, key, "GameObject") for name, key in PREFAB_KEYS.items()}, + "settings": {name: key_object(objects, key, cls) for name, (key, cls) in SETTINGS_KEYS.items()}, + "graphics": graphics, + "bindings": animator_bindings(objects), + "shaders": shaders, + } + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + return doc, keep, roles diff --git a/src/nnnotes/content/storysimpleui.py b/src/nnnotes/content/storysimpleui.py new file mode 100644 index 0000000..805f444 --- /dev/null +++ b/src/nnnotes/content/storysimpleui.py @@ -0,0 +1,75 @@ +"""Localized talk-window and system-message objects for Overlay stories.""" + +from __future__ import annotations + +from ..source import languages +from ..unity import textstyle +from . import storyui +from .livenotes import animator_bindings +from .prefabs import builtin_root, key_object, mesh_roles, mono_json, ref_id, shader_table + +FORMAT = "nnnotes.story-simple-ui/1" +ADDRESSES = { + "talkWindow": "UI/Prefab/Parts/Adv/Talk/UISimpleAdvTalkWindow", + "systemMessage": "UI/Prefab/UISystemMessageWidget", +} + + +def addresses(catalog) -> dict[str, str]: + """The plain catalog address of each prefab, or its embedded address.""" + held = set(catalog) + out = {} + for name, address in ADDRESSES.items(): + found = next((key for key in (address, f"Emb{address}") if key in held), None) + if found is None: + raise ValueError(f"{address}: no plain or embedded prefab in the catalog") + out[name] = found + return out + + +def keys(catalog) -> list[str]: + return sorted({*addresses(catalog).values(), storyui.UI_MANAGER_KEY}) + + +def ui(objects, language: str, settings) -> tuple[dict, set, dict]: + """ui.json with component-id text styles, prefab roots, animation bindings and shader references.""" + code = languages.check(language) + mode = languages.mode(code) + localize = storyui.script_root(objects, "LocalizeManager") + lang = textstyle.language_fonts_json(mono_json(objects, localize)) + styles = storyui._Styles(lang, mode, settings) + roots = {name: key_object(objects, key, "GameObject") for name, key in addresses(objects.keys()).items()} + roots["uiManager"] = key_object(objects, storyui.UI_MANAGER_KEY, "GameObject") + texts = {} + for oid in roots.values(): + texts.update(storyui._texts(objects, oid, styles)) + graphics = builtin_root(objects, "GraphicsSettings") + found = [ref_id(r) for r in objects.json(graphics).get("m_AlwaysIncludedShaders", ())] + shaders, roles = shader_table(objects, found=[oid for oid in found if oid is not None]) + doc = { + "format": FORMAT, + "language": { + "code": code, + "mode": mode, + "field": languages.column(code)[1:], + "lineSpacing": textstyle.LANGUAGE_LINE_SPACING[mode], + "fontNames": lang["languages"][mode]["fontNames"], + "additionalFontNames": lang["languages"][mode]["additionalFontNames"], + "materialTypes": lang["materialTypes"], + "fontSwap": styles.swap, + }, + **roots, + "settings": { + "localizeManager": localize, + "tmpSettings": storyui.script_root(objects, "TMP_Settings"), + "dotween": storyui.script_root(objects, "DOTweenSettings"), + }, + "graphics": {"graphics": graphics}, + "texts": texts, + "textStyle": styles.summary(), + "bindings": animator_bindings(objects), + "shaders": shaders, + } + keep = {oid for oid in objects.ids() if objects.info(oid).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + return doc, keep, roles diff --git a/src/nnnotes/content/storyui.py b/src/nnnotes/content/storyui.py new file mode 100644 index 0000000..71204e6 --- /dev/null +++ b/src/nnnotes/content/storyui.py @@ -0,0 +1,308 @@ +"""The ADV story UI of a product: what the ADV front canvas, its talk windows, the chat phone, the dialogs and the +settings draw with in one client language. One product (`ui`, ui.json format UI_FORMAT), read from a product's object +table (`Objects`, pipeline.assemble) the way storyscene reads the story scene: + + widget the GameObject of UIAdvWidget (WIDGET_KEY); its prefab document holds the drawn hierarchy + talkWindows {window name: GameObject} of the talk windows of the catalog (window_keys); the default window + UIDefaultTalkWindow is always one of them + chatWidget the GameObject of the phone of the chat rows (UIAdvChatWidget) + dialogs {name: GameObject} of the skip confirm dialog and the common dialog (DIALOG_KEYS) + uiManager the GameObject of UIManager (its camera controller and the UI camera) + letterbox the Sprite of the letterbox bands + clips {name: AnimationClip} of the ADV animations (CLIP_KEYS) + controllers {name: AnimatorController} of the ADV animations (CONTROLLER_KEYS) + settings the AdvMasterIdSettings, DOTweenSettings, TMP_Settings and LocalizeManager objects + graphics {graphics: GraphicsSettings} + language the client language: its code, LanguageMode, text field and line spacing, the font names and + material types LocalizeManager holds for it (textstyle.language_fonts_json) and the font swap + texts {TMP component id: {localized, style}} of every TMP text of the prefabs above. `localized` is + LocalizeText's font asset, material and line spacing (textstyle.localize_text), null when the + text has no enabled LocalizeText; `style` is the text record (textstyle.text_style) with the font + settings read from the client's font assets + textStyle the language's field, the units and the line metrics of the font roles the texts use + masterIdTexts the AdvMasterIdSettings texts of MASTER_ID_TEXTS: {field: {id, text}} + chatStatusTexts the incoming-call and lock-screen texts: {MasterText key: text} + bindings the animator bindings of the prefabs (livenotes.animator_bindings) + shaders {shader name: object id} (prefabs.shader_table) + +A text's font asset and material are named by the object the table holds for them, or by the class and name the table +fences them with (Objects.fenced): a reference across the font fence carries only its file and path id. + +Of the objects the document keeps all but the MonoScripts; of a shader its parsed form, variant index and the programs +of the player's graphics API; of a Mesh its typetree JSON and its arrays (prefabs.mesh_roles). + +The master record is {"texts": {MasterText id: text in the language's text field}}. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from ..source import languages +from ..unity import fontsettings, textstyle +from ..unity.ids import object_id, parse_object_id +from . import adv +from .livenotes import animator_bindings +from .prefabs import ( + Objects, + builtin_root, + component_class, + key_object, + mesh_roles, + mono_json, + ref_id, + script_class, + shader_table, +) + +UI_FORMAT = 1 # ui.json "format" +WIDGET_KEY = "EmbUI/Prefab/UIAdvWidget" +WINDOW_KEY = "EmbUI/Prefab/Parts/Adv/Talk/UIDefaultTalkWindow" # the default talk window (embedded address) +CHAT_WIDGET_KEY = "EmbUI/Prefab/UIAdvChatWidget" +UI_MANAGER_KEY = "EmbCommon/Prefab/UIManager" +LETTERBOX_KEY = "Textures/letterbox-image" +MASTER_ID_SETTINGS_KEY = "EmbCommon/Adv/Settings/AdvMasterIdSettings" +DIALOG_KEYS = { + "UIAdvSkipConfirmDialogWidget": "EmbUI/Prefab/UIAdvSkipConfirmDialogWidget", + "UICommonDialogWidget": "EmbUI/Prefab/UICommonDialogWidget", +} +CLIP_KEYS = { + "NextIndicator/Loop": "EmbUI/Animations/NextIndicator/Loop", + "AutoNext/Loop": "EmbUI/Animations/Auto/Loop", + "AdvLocation/Play": "EmbUI/Animations/AdvLocation/Play", + "AdvTitle/Play": "EmbUI/Animations/AdvTitle/Play", +} +CONTROLLER_KEYS = { + "NextIndicator": "EmbUI/Animations/NextIndicator/NextIndicator", + "AutoNext": "EmbUI/Animations/Auto/AutoNext", + "AdvLocation": "EmbUI/Animations/AdvLocation/AdvLocation", + "AdvTitle": "EmbUI/Animations/AdvTitle/AdvTitle", +} +# AdvMasterIdSettings fields of the texts the drawn parts show: the video skip dialog, the skip confirm dialog and the +# interruption dialog +MASTER_ID_TEXTS = ( + "_skipVideoMessageTextId", + "_skipButtonTextId", + "_cancelButtonTextId", + "_skipMessageTextId", + "_continuousSkipMessageTextId", + "_backEpisodeListButtonTextId", + "_continueEpisodeButtonTextId", + "_interruptionTitleTextId", + "_interruptionMessageTextId", + "_interruptionButtonTextId", +) +TEXT_CLASSES = ("TextMeshProUGUI", "RubyTextMeshProUGUI", "RubyEmojiTextMeshProUGUI") +# the built-in objects and the Resources.Load paths of the document (ProductStage.builtins, ProductStage.resources) +BUILTINS = ("GraphicsSettings", "MonoBehaviour:LocalizeManager") +RESOURCES = ("DOTweenSettings", "TMP Settings") +CHAT_STATUS_KEYS = ("_incomingCallStatusTextKey", "_lockScreenStatusTextKey") + + +def window_keys(catalog: Iterable[str]) -> dict[str, str]: + """{talk window name: prefab key} of the talk windows of the catalog keys `catalog`: the names under the talk window + prefix or its embedded form, each with its plain key when the catalog has that and else its embedded key (the + rule of content.advui.window_key), and the default window.""" + keys = set(catalog) + prefixes = (adv.TALK_WINDOW_PREFIX, adv.EMBEDDED_PREFIX + adv.TALK_WINDOW_PREFIX) + names = {k[len(p) :] for k in keys for p in prefixes if k.startswith(p)} | {adv.DEFAULT_TALK_WINDOW} + out = {} + for name in sorted(names): + if name == adv.DEFAULT_TALK_WINDOW: + out[name] = WINDOW_KEY + else: + address = adv.TALK_WINDOW_PREFIX + name + out[name] = address if address in keys else adv.EMBEDDED_PREFIX + address + return out + + +def keys(catalog: Iterable[str]) -> list[str]: + """The catalog keys of the story UI in the catalog `catalog` (its keys): the widget, the chat widget, UIManager, the + letterbox, the master id settings, the dialogs, the animations and the talk windows (window_keys), sorted.""" + return sorted( + { + WIDGET_KEY, + CHAT_WIDGET_KEY, + UI_MANAGER_KEY, + LETTERBOX_KEY, + MASTER_ID_SETTINGS_KEY, + *DIALOG_KEYS.values(), + *CLIP_KEYS.values(), + *CONTROLLER_KEYS.values(), + *window_keys(catalog).values(), + } + ) + + +def font_keys(catalog: Iterable[str]) -> list[str]: + """The keys of font assets and materials under the client's font folders, sorted.""" + return sorted({k for k in catalog if k.startswith((fontsettings.FONT_PREFIX, fontsettings.FONT_ADDRESS))}) + + +def chat_window_keys(catalog: Iterable[str]) -> list[str]: + """The chat-window prefab keys from which the shared status-text keys are read, sorted.""" + return sorted({k for k in catalog if k.startswith(adv.CHAT_WINDOW_PREFIX)}) + + +def chat_status_texts(objects: Objects, texts: dict) -> dict[str, str]: + """The incoming-call and lock-screen texts of the chat windows in `objects`, resolved in `texts`.""" + wanted = set() + for key in chat_window_keys(objects.keys()): + prefab = key_object(objects, key, "GameObject") + for node in objects.json(prefab)["nodes"]: + for comp in node["components"]: + if component_class(comp) == "AdvChatWindow": + wanted.update(str(comp["fields"][k]) for k in CHAT_STATUS_KEYS if comp["fields"].get(k)) + return {k: texts[k] for k in sorted(wanted)} + + +def ui( + objects: Objects, + language: str, + master: dict, + fonts: fontsettings.FontSettings, + *, + status_texts: dict[str, str] | None = None, +) -> tuple[dict, set[str], dict[str, set[str]]]: + """(ui.json, the object ids the player reads, {object id: the roles of its files it reads}) of the story UI in the + client language `language` (module documentation).""" + code = languages.check(language) + mode, field = languages.mode(code), languages.column(code)[1:] + localize = script_root(objects, "LocalizeManager") + lang = textstyle.language_fonts_json(mono_json(objects, localize)) + styles = _Styles(lang, mode, fonts) + widget = key_object(objects, WIDGET_KEY, "GameObject") + windows = {name: key_object(objects, key, "GameObject") for name, key in window_keys(objects.keys()).items()} + chat = key_object(objects, CHAT_WIDGET_KEY, "GameObject") + ui_manager = key_object(objects, UI_MANAGER_KEY, "GameObject") + dialogs = {name: key_object(objects, key, "GameObject") for name, key in DIALOG_KEYS.items()} + texts = {} + for prefab in (widget, *windows.values(), chat, *dialogs.values(), ui_manager): + texts.update(_texts(objects, prefab, styles)) + settings = key_object(objects, MASTER_ID_SETTINGS_KEY, "AdvMasterIdSettings") + graphics = builtin_root(objects, "GraphicsSettings") + always = (ref_id(r) for r in objects.json(graphics).get("m_AlwaysIncludedShaders", ())) + shaders, roles = shader_table(objects, found=[o for o in always if o is not None]) + doc = { + "format": UI_FORMAT, + "language": { + "code": code, + "mode": mode, + "field": field, + "lineSpacing": textstyle.LANGUAGE_LINE_SPACING[mode], + "fontNames": lang["languages"][mode]["fontNames"], + "additionalFontNames": lang["languages"][mode]["additionalFontNames"], + "materialTypes": lang["materialTypes"], + "fontSwap": styles.swap, + }, + "widget": widget, + "talkWindows": windows, + "chatWidget": chat, + "dialogs": dialogs, + "uiManager": ui_manager, + "letterbox": key_object(objects, LETTERBOX_KEY, "Sprite"), + "clips": {name: key_object(objects, key, "AnimationClip") for name, key in CLIP_KEYS.items()}, + "controllers": {name: key_object(objects, key, "AnimatorController") for name, key in CONTROLLER_KEYS.items()}, + "settings": { + "masterIdSettings": settings, + "dotween": script_root(objects, "DOTweenSettings"), + "tmpSettings": script_root(objects, "TMP_Settings"), + "localizeManager": localize, + }, + "graphics": {"graphics": graphics}, + "texts": texts, + "textStyle": styles.summary(), + "masterIdTexts": master_id_texts(objects.json(settings), master["texts"]), + "chatStatusTexts": status_texts if status_texts is not None else chat_status_texts(objects, master["texts"]), + "bindings": animator_bindings(objects), + "shaders": shaders, + } + keep = {o for o in objects.ids() if objects.info(o).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + return doc, keep, roles + + +def master_id_texts(settings: dict, texts: dict) -> dict: + """masterIdTexts: {AdvMasterIdSettings field of MASTER_ID_TEXTS: {id (MasterText id), text}}; `texts` is the master + record's {MasterText id: text} (JSON keys are strings). A missing id raises KeyError.""" + return {k: {"id": settings[k], "text": texts[str(settings[k])]} for k in MASTER_ID_TEXTS} + + +class _Styles: + """The text records of one language: LocalizeText's resolution of a text, the style values of its material, and the + line metrics of the font roles the texts use.""" + + def __init__(self, lang: dict, mode: int, fonts: fontsettings.FontSettings): + self.lang, self.mode, self.fonts = lang, mode, fonts + self.swap = textstyle.font_swap(lang, mode) + self.roles: dict[str, dict] = {} + self._styles: dict[tuple, dict] = {} + + def _face(self, font_asset: str) -> tuple[dict, dict]: + f = self.fonts.font(font_asset) + return f["faceInfo"], {k: f[k] for k in ("normalSpacingOffset", "boldSpacing")} + + def record(self, path: str, comp: dict, localize: dict | None) -> dict: + """{localized, style} of the TMP text `comp` (serialized fields, font and material named) with its LocalizeText + `localize` (or None).""" + fa, mat = comp["m_fontAsset"]["name"], comp["m_sharedMaterial"]["material"] + localized = None + if localize and localize["m_Enabled"] and localize["_localizeEnabled"]: + localized = textstyle.localize_text(path, fa, mat, self.swap, self.lang, self.mode) + fa, mat = localized["fontAsset"], localized["material"] + if (fa, mat) not in self._styles: + face, _ = self._face(fa) + self._styles[(fa, mat)] = textstyle.material_style( + self.fonts.material(fa, mat), face["m_PointSize"], face["m_Scale"] + ) + style = textstyle.text_style(comp, localize, self.lang, self._styles[(fa, mat)], self.mode) + if style["fontRole"] not in self.roles: + face, extra = self._face(fa) + self.roles[style["fontRole"]] = textstyle.face_metrics(face, extra) + return {"localized": localized, "style": style} + + def summary(self) -> dict: + return {"language": textstyle.LANGUAGE_FIELDS[self.mode], "units": textstyle.UNITS, "roles": self.roles} + + +def _texts(objects: Objects, prefab: str, styles: _Styles) -> dict[str, dict]: + """{TMP component id: {localized, style}} of the TMP texts of the prefab document of GameObject `prefab`. + A component id is the prefab's file and the component's path id.""" + file, _ = parse_object_id(prefab) + out = {} + for n in objects.json(prefab)["nodes"]: + tmp = [c for c in n["components"] if component_class(c) in TEXT_CLASSES] + loc = [c for c in n["components"] if component_class(c) == "LocalizeText"] + if not tmp: + continue + if len(tmp) > 1 or len(loc) > 1: + raise ValueError(f"{n['path']}: {len(tmp)} TMP texts and {len(loc)} LocalizeText components") + comp = {"class": component_class(tmp[0]), **tmp[0]["fields"]} + comp["m_fontAsset"] = {"name": _name(objects, comp["m_fontAsset"])} + comp["m_sharedMaterial"] = {"material": _name(objects, comp["m_sharedMaterial"])} + localize = {"class": "LocalizeText", **loc[0]["fields"]} if loc else None + out[object_id(file, int(tmp[0]["pathId"]))] = styles.record(n["path"], comp, localize) + return out + + +def _name(objects: Objects, ref) -> str: + """The name of the object a reference names: from the table, or from the class and name the table fences it with.""" + oid = ref_id(ref) + if oid is None: + raise ValueError(f"not a reference to an object: {ref!r}") + hit = objects.info(oid) if oid in objects else objects.fenced(oid) + if hit is None or not hit.get("name"): + raise ValueError(f"{oid}: no name in the object table") + return hit["name"] + + +def script_root(objects: Objects, script: str) -> str: + """The one MonoBehaviour root of the script class `script` (ValueError otherwise).""" + hits = [ + o + for o in objects.roots + if o in objects and objects.info(o).get("class") == "MonoBehaviour" and script_class(objects, o) == script + ] + if len(hits) != 1: + raise ValueError(f"{len(hits)} {script} roots") + return hits[0] diff --git a/src/nnnotes/content/storyvideo.py b/src/nnnotes/content/storyvideo.py new file mode 100644 index 0000000..305b6fd --- /dev/null +++ b/src/nnnotes/content/storyvideo.py @@ -0,0 +1,57 @@ +"""The document of one story video: a Movie / Clip command's `Cri/Video/` key. + +The key loads a CriWare.Assets MonoBehaviour that describes the movie (`movieInfo`: size, display size, frame rate, +frame count, codec) and the movie itself, a USM. cri.movie converts the USM to `movie.webm` (VP9 as stored, or +re-encoded as VP9 when the video is in another codec or has an alpha stream, the alpha becoming the WebM's alpha +channel; the first audio stream as Opus). video.json carries the movie's description and the tracks of `movie.webm`, +so the product keeps no objects. +""" + +from __future__ import annotations + +import re + +from .prefabs import Objects, has_json + +__all__ = ["CODECS", "KEY_PREFIX", "VIDEO_FORMAT", "manifest_name", "video"] + +VIDEO_FORMAT = 1 # video.json "format" +KEY_PREFIX = "Cri/Video/" +ASSET = re.compile(r"[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*") # the asset part of a video key +CODECS = {1: "sofdec.prime", 5: "h264", 9: "vp9"} # movieInfo.codecType (CriMana.CodecType) + + +def manifest_name(key: str) -> str: + """The manifest name of the video key `key` (`Cri/Video/`): `` with "/" as "~" (ValueError for + another key).""" + asset = key[len(KEY_PREFIX) :] if key.startswith(KEY_PREFIX) else "" + if not ASSET.fullmatch(asset): + raise ValueError(f"{key!r}: not a video key ({KEY_PREFIX})") + return asset.replace("/", "~") + + +def _info(objects: Objects, key: str) -> dict: + """The `movieInfo` of the CriWare.Assets MonoBehaviour among the objects of `key`.""" + hits = [o for o in objects.key(key) if has_json(objects, o) and "movieInfo" in objects.json(o)] + if len(hits) != 1: + raise ValueError(f"{key}: {len(hits)} MonoBehaviours with a movieInfo, expected one") + return objects.json(hits[0])["movieInfo"] + + +def video(objects: Objects, key: str, tracks: list[dict]) -> dict: + """video.json of the video key `key`: `format`, `file` (movie.webm), the movie's `codec` (as stored), `width` and + `height` (the coded size), `displayWidth` and `displayHeight`, `frameRate` [numerator, denominator], `frames`, and + `tracks`, those of movie.webm (cri.movie: kind, channel, codec and, for a converted stream, its `source` codec).""" + mi = _info(objects, key) + return { + "format": VIDEO_FORMAT, + "file": "movie.webm", + "codec": CODECS.get(mi["codecType"], f"codecType {mi['codecType']}"), + "width": mi["width"], + "height": mi["height"], + "displayWidth": mi["dispWidth"], + "displayHeight": mi["dispHeight"], + "frameRate": [mi["framerateN"], mi["framerateD"]], + "frames": mi["totalFrames"], + "tracks": tracks, + } diff --git a/src/nnnotes/content/tmpassets.py b/src/nnnotes/content/tmpassets.py new file mode 100644 index 0000000..d134750 --- /dev/null +++ b/src/nnnotes/content/tmpassets.py @@ -0,0 +1,287 @@ +"""TextMesh Pro fonts, materials and texture pages read from shared object tables. + +Font and FontSet implement the glyph-generation protocols in unity.tmpfont. Texture supplies decoded RGBA +texels to unity.texels.MemoryPacker. build_font_settings assembles the localized font settings table.""" + +from __future__ import annotations + +import copy +import hashlib +import io +from collections.abc import Callable +from typing import cast + +import numpy as np +from PIL import Image + +from ..unity.fontsettings import FONT_CLASS, font_key +from ..unity.ids import parse_object_id +from ..unity.texels import texel_rows +from ..unity.tmpfont import ( + MISSING_GLYPH_CHARACTER, + BuiltFontSettings, + FontAsset, + FontLookup, + font_cmap, + font_document, + font_record, + load_listed, +) +from .prefabs import Objects, key_object, ref_id + + +def _ref(v) -> str | None: + """The object id a reference value of the table names (None for null); a reference the table does not resolve + raises.""" + if v is None: + return None + oid = ref_id(v) + if oid is None: + raise ValueError(f"reference {v!r} does not resolve in the object table") + return oid + + +class _Keys: + """The catalog's `has` (fontsettings.font_key) over the keys of an object table.""" + + def __init__(self, objects: Objects): + self.objects = objects + + def has(self, key: str) -> bool: + try: + self.objects.key(key) + except KeyError: + return False + return True + + +class Texture: + """A Texture2D of an object table: its record (name, size, format, mip count and sampler settings, from its + `json`) and its RGBA texels, bottom row first, decoded from its `image` file.""" + + def __init__(self, objects: Objects, oid: str): + self.oid = oid + self._objects = objects + tt = objects.json(oid) + self.key: tuple[str, int] = parse_object_id(oid) + self.name: str = tt["m_Name"] + self.width: int = tt["m_Width"] + self.height: int = tt["m_Height"] + self.format: int = tt["m_TextureFormat"] + self.mip_count: int = tt.get("m_MipCount", 1) + self.settings: dict = tt["m_TextureSettings"] + self._pixels: np.ndarray | None = None + + @property + def pixels(self) -> np.ndarray: + """The texels as RGBA, bottom row first (read-only), decoded once.""" + if self._pixels is None: + ext = self._objects.info(self.oid)["files"]["image"]["ext"] + if ext != "png": + raise NotImplementedError(f"texture {self.name}: image file {ext}") + img = Image.open(io.BytesIO(self._objects.bytes(self.oid, "image"))) + arr = texel_rows(img, self.format, self.width, self.height, self.name) + arr.setflags(write=False) + self._pixels = arr + return self._pixels + + def record(self) -> dict: + """The texture reference and its dimensions and sampler settings.""" + return { + "textureRef": self.oid, + "name": self.name, + "width": self.width, + "height": self.height, + "format": self.format, + "mipCount": self.mip_count, + "settings": self.settings, + } + + +class Font: + """A TMP_FontAsset of an object table: the fields tmpfont.Font reads, its atlas pages (Texture, None for a missing + page), the bytes and character map of its source font file, and its default material.""" + + def __init__(self, fonts: FontSet, oid: str): + objects = fonts.objects + tt = objects.json(oid) + self.oid = oid + self._fonts = fonts + self.tt = tt + self.name: str = tt["m_Name"] + self.chars = {c["m_Unicode"]: c for c in tt["m_CharacterTable"]} + self.glyphs = {g["m_Index"]: g for g in tt["m_GlyphTable"]} + self.dynamic = tt["m_AtlasPopulationMode"] == 1 + self.atlases: list[Texture | None] = [] + for p in tt["m_AtlasTextures"]: + page = _ref(p) + self.atlases.append(None if page is None else fonts.texture(page)) + material = _ref(tt["m_Material"]) + if material is None: + raise KeyError(f"{self.name}: a required reference is null (m_Material)") + self.material_oid: str = material + self.fallback_oids: list[str | None] = [_ref(p) for p in tt["m_FallbackFontAssetTable"]] + if any(_ref(p["regularTypeface"]) or _ref(p["italicTypeface"]) for p in tt["m_FontWeightTable"]): + raise NotImplementedError(f"{self.name}: font weight table typefaces") + self.cmap: set[int] = set() + self.variants: dict[tuple[int, int], str] = {} + self.font_data: bytes | None = None + self.source_font: dict | None = None + src = _ref(tt["m_SourceFontFile"]) + if src is not None: + data = objects.bytes(src, "font") + self.cmap, self.variants = font_cmap(data, tt["m_FaceInfo"]["m_FaceIndex"]) + self.font_data = data + self.source_font = { + "name": objects.json(src)["m_Name"], + "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + } + + def atlas_names(self) -> list[str | None]: + return [None if a is None else a.name for a in self.atlases] + + def atlas_settings(self) -> list[dict]: + return [a.settings for a in self.atlases if a is not None] + + def default_material(self) -> dict: + """The font asset's default material record.""" + return self._fonts.material(self.material_oid) + + +class FontSet(FontLookup): + """The font assets of an object table reached from a font asset (through their fallback chains), with the TMP + character lookup of FontLookup.""" + + def __init__(self, objects: Objects): + super().__init__() + self.objects = objects + self._by_oid: dict[str, Font] = {} + self._textures: dict[str, Texture] = {} + + def texture(self, oid: str) -> Texture: + if oid not in self._textures: + cls = self.objects.info(oid).get("class") + if cls != "Texture2D": + raise NotImplementedError(f"{oid}: a texture reference to a {cls}") + self._textures[oid] = Texture(self.objects, oid) + return self._textures[oid] + + def by_key(self, key: str) -> Font: + """The font asset of the catalog key `key` (its font folder's TMP_FontAsset).""" + return self.of(key_object(self.objects, key, FONT_CLASS)) + + def loaded(self, name: str) -> Font: + """The font asset `name`, read by `of` or `by_key`.""" + return cast(Font, self.fonts[name]) + + def of(self, oid: str) -> Font: + if oid not in self._by_oid: + f = Font(self, oid) + if f.name in self.fonts: + raise RuntimeError(f"two font assets named {f.name}") + self._by_oid[oid] = f + self.fonts[f.name] = f + self._fallback_names[f.name] = [self.of(fb).name for fb in f.fallback_oids if fb is not None] + return self._by_oid[oid] + + def material(self, oid: str) -> dict: + """The record of the Material `oid`: its shader by name, its textures as texture + records, its other fields as in its `json`.""" + doc = self.objects.json(oid) + shader = _ref(doc["shader"]) + return { + "material": doc["material"], + "shader": None if shader is None else {"shader": self.objects.json(shader)["name"]}, + "keywords": doc["keywords"], + "invalidKeywords": doc["invalidKeywords"], + "renderQueue": doc["renderQueue"], + "tags": doc["tags"], + "disabledPasses": doc["disabledPasses"], + "textures": {k: self._texture_entry(v) for k, v in doc["textures"].items()}, + "ints": doc["ints"], + "floats": doc["floats"], + "colors": doc["colors"], + } + + def _texture_entry(self, v: dict) -> dict: + tex = _ref(v["texture"]) + return { + "texture": None if tex is None else self.texture(tex).record(), + "scale": v["scale"], + "offset": v["offset"], + } + + def named_material(self, font: FontAsset, name: str) -> dict: + """The material `name` of the font asset `font`, at its key in the font's folder (tmpfont.material_by_name).""" + key = font_key(_Keys(self.objects), font.name, name) + if not _Keys(self.objects).has(key): + raise KeyError(f"material key {key}") + return self.material(key_object(self.objects, key, "Material")) + + +class TableFontSettings(BuiltFontSettings): + """FontSettings read from the font assets of an object table (FontSet): each font asset's bundle from + `bundle_of(name)`, the records required by the font settings table.""" + + def __init__(self, fonts: FontSet, bundle_of: Callable[[str], str]): + super().__init__() + self._set = fonts + self._keys = _Keys(fonts.objects) + self._bundle_of = bundle_of + + def font(self, name: str) -> dict: + if name not in self.fonts: + if name not in self._set.fonts: + self._set.by_key(font_key(self._keys, name, name)) + f = self._set.loaded(name) + mat = f.default_material() + self.materials.setdefault(mat["material"], mat) + self.material_ids.setdefault(mat["material"], parse_object_id(f.material_oid)[1]) + self.font_ids[name] = parse_object_id(f.oid)[1] + fallbacks = [fb.name for fb in self._set.fallbacks(f)] + self.fonts[name] = font_record(f, mat["material"], fallbacks, self._bundle_of(name)) + return self.fonts[name] + + def has_material(self, font: str, name: str) -> bool: + return name in self.materials or self._keys.has(font_key(self._keys, font, name)) + + def material(self, font: str | None, name: str) -> dict: + if name not in self.materials: + if font is None: + raise KeyError(f"material {name}: not read yet, and no font asset to find it by") + key = font_key(self._keys, font, name) + if not self._keys.has(key): + raise KeyError(f"material key {key}") + oid = key_object(self._set.objects, key, "Material") + self.material_ids[name] = parse_object_id(oid)[1] + self.materials[name] = self._set.material(oid) + return copy.deepcopy(self.materials[name]) + + def where(self) -> str: + return "the font assets of the object table" + + +def build_font_settings(objects: Objects, lang: dict, game_version: str, bundle_of: Callable[[str], str]) -> dict: + """The font settings table (fontsettings.FORMAT) read from the font assets of an object table: the document + tmpfont.build_font_settings gives for the game's assets. `lang` is LocalizeManager's font data + (textstyle.language_fonts), `bundle_of(name)` the bundle that holds the font asset `name`.""" + settings = TableFontSettings(FontSet(objects), bundle_of) + return font_document(settings, load_listed(settings, lang), game_version) + + +def missing_glyph_character(objects: Objects) -> int: + """TMP_Settings.missingGlyphCharacter of the table's TMP Settings resource (tmpfont.missing_glyph_character).""" + hits = [ + o + for o in objects.roots + if o in objects + and objects.info(o).get("name") == "TMP Settings" + and objects.info(o).get("class") == "MonoBehaviour" + ] + if len(hits) != 1: + raise ValueError(f"TMP Settings: {len(hits)} resources in the table, expected one") + tt = objects.json(hits[0]) + if tt["m_fallbackFontAssets"] or _ref(tt["m_defaultFontAsset"]) is not None: + raise NotImplementedError("TMP Settings fallback font assets / default font asset") + return tt["m_missingGlyphCharacter"] or MISSING_GLYPH_CHARACTER diff --git a/src/nnnotes/content/ui.py b/src/nnnotes/content/ui.py index 1e929b8..2566ed1 100644 --- a/src/nnnotes/content/ui.py +++ b/src/nnnotes/content/ui.py @@ -1,420 +1,81 @@ -"""Serialized UI library for ournotes-player/ui, from user-supplied game files. - -Outputs contain game data and belong outside either source repository. This is -an inspectable prefab preview format, not a claim of Unity runtime parity. -""" +"""UI prefab libraries assembled from serialized object products.""" from __future__ import annotations import hashlib -import json from collections import Counter -from pathlib import Path - -from ..core.cache import write_atomic -from ..core.jsonio import dumps, write_json -from ..unity.export import Exporter - - -def identity(value: str) -> str: - return hashlib.sha256(value.encode("utf-8")).hexdigest()[:24] - - -def object_id(obj) -> str: - return identity(f"{obj.assets_file.name}:{obj.path_id}") - - -class UIExporter(Exporter): - """The generic Exporter plus portable images and stable PPtr identities.""" - - def ref(self, owner, pointer): - out = super().ref(owner, pointer) - if not isinstance(out, dict): - return out - if "gameObject" not in out and "transform" not in out and out.get("object") != "AnimatorController": - return out - obj = self.deref(owner, pointer) - if obj is None: - return out - if obj.type.name == "AnimatorController": - return {**out, "id": object_id(obj)} - go = obj if obj.type.name == "GameObject" else self.deref(obj, obj.read_typetree()["m_GameObject"]) - return {**out, "nodeId": object_id(go)} if go is not None else out - - def hierarchy(self, env, graph, root_tf): - nodes = super().hierarchy(env, graph, root_tf) - transforms = [] - - def walk(pid): - transforms.append(pid) - for child in graph.tf[pid]["m_Children"]: - walk(child["m_PathID"]) - walk(root_tf) - for node, pid in zip(nodes, transforms, strict=True): - go_pid = graph.tf[pid]["m_GameObject"]["m_PathID"] - node["nodeId"] = identity(f"{self._go_file[go_pid]}:{go_pid}") - return nodes - - def sprite(self, obj): - try: - return super().sprite(obj) - except NotImplementedError as exc: - if "packing rotation" not in str(exc): - raise - tt = obj.read_typetree() - image = obj.read().image.convert("RGBA") - key = f"{obj.assets_file.name}:{obj.path_id}" - texture_key = "decoded-sprite:" + key - if not hasattr(self, "decoded_sprites"): - self.decoded_sprites = {} - self.decoded_sprites[texture_key] = image - self.sprite_recs[key] = { - "name": tt["m_Name"], - "textureRef": texture_key, - "rect": {"x": 0, "y": 0, "width": image.width, "height": image.height}, - "textureRect": {"x": 0, "y": 0, "width": image.width, "height": image.height}, - "textureRectOffset": {"x": 0, "y": 0}, - "border": tt["m_Border"], - "pivot": tt["m_Pivot"], - "pixelsPerUnit": tt["m_PixelsToUnits"], - "settingsRaw": 0, - } - return {"spriteRef": key, "name": tt["m_Name"]} - - def atlas(self, obj): - tt = obj.read_typetree() - return { - "atlas": tt["m_Name"], - "sprites": [ - self.sprite(sprite) - for ptr in tt.get("m_PackedSprites", []) - if (sprite := self.deref(obj, ptr)) is not None - ], - } - - -def _font_metrics(ex, document, out): - used = { - c["m_fontAsset"]["name"] - for n in document.get("nodes", []) - for c in n["components"] - if isinstance(c.get("m_fontAsset"), dict) and "VibeMO" in c["m_fontAsset"].get("name", "") - } - if not used: - return {} - (out / "fonts").mkdir(parents=True, exist_ok=True) - result = {} - for obj in ex._objects.values(): - if obj.type.name != "MonoBehaviour" or ex.script_class(obj) != "TMP_FontAsset": - continue - tt = obj.read_typetree() - if tt.get("m_Name") not in used: - continue - atlas = ex.deref(obj, tt["m_AtlasTextures"][0]) - if atlas is None: - raise ValueError(f"UI font {tt['m_Name']}: no atlas") - key = f"{atlas.assets_file.name}:{atlas.path_id}" - ex.tex_objs[key] = atlas - chars = {str(c["m_Unicode"]): c["m_GlyphIndex"] for c in tt["m_CharacterTable"] if 32 <= c["m_Unicode"] < 127} - glyphs = {str(g["m_Index"]): g for g in tt["m_GlyphTable"] if g["m_Index"] in chars.values()} - rel = f"fonts/{object_id(obj)}.json" - write_json( - out / rel, +from .prefabs import clip_bindings, component_class, controller_clips, has_json, prefab_ids, shader_table + +FORMAT = "nnnotes.ui-pack/1" +LIBRARY = "nnnotes.ui-library/1" + + +def identity(key: str) -> str: + """A stable file name for a catalog key.""" + return hashlib.sha256(key.encode("utf-8")).hexdigest()[:24] + + +def pack(objects, key: str) -> tuple[dict, dict]: + """The UI roots, reachable prefabs and controllers, with per-prefab animation bindings.""" + roots = sorted(objects.key(key)) + if not roots: + raise ValueError(f"{key}: no UI objects") + prefabs = prefab_ids(objects) + controllers = [oid for oid in objects.ids("AnimatorController") if has_json(objects, oid)] + bindings = {} + for oid in prefabs: + document = objects.json(oid) + bindings[oid] = clip_bindings(objects, document, controller_clips(objects, document)) + shaders, roles = shader_table(objects) + entries = [] + for oid in sorted(set(roots) | set(prefabs) | set(controllers)): + info = objects.info(oid) + entry = {"id": oid, "name": info.get("name", oid), "class": info["class"]} + if oid in prefabs: + nodes = objects.json(oid)["nodes"] + entry.update( + nodeCount=len(nodes), + classes=dict(sorted(Counter(component_class(c) for n in nodes for c in n["components"]).items())), + ) + entries.append(entry) + return { + "format": FORMAT, + "key": key, + "roots": roots, + "prefabs": prefabs, + "controllers": controllers, + "entries": entries, + "bindings": bindings, + "shaders": shaders, + }, roles + + +def library(packs) -> dict: + """A library index of (manifest path, UI document) pairs.""" + assets, embedded, controllers = [], {}, {} + for manifest, doc in sorted(packs): + rows = {e["id"]: e for e in doc["entries"]} + primary = rows[doc["roots"][0]] + assets.append( { - "name": tt["m_Name"], - "face": tt["m_FaceInfo"], - "characters": chars, - "glyphs": glyphs, - "texture": f"../textures/{identity(key)}.png", - "textureBase": "metrics", - }, + "id": identity(doc["key"]), + "key": doc["key"], + "name": doc["key"].rsplit("/", 1)[-1], + "kind": "prefab" if primary["class"] == "GameObject" else primary["class"], + "manifest": manifest, + "roots": doc["roots"], + "nodeCount": sum(rows[oid].get("nodeCount", 0) for oid in doc["roots"]), + } ) - result[tt["m_Name"]] = rel - if used - result.keys(): - raise ValueError("UI font metrics not present in the loaded dependency closure") - return result - - -def resources(ex, document, out: Path, written=None) -> dict: - written = written if written is not None else set() - result = {"textures": {}, "sprites": {}, "fonts": {}} - metrics = _font_metrics(ex, document, out) - if metrics: - result["fontMetrics"] = next(iter(metrics.values())) - result["fontMetricsByAsset"] = metrics - (out / "textures").mkdir(parents=True, exist_ok=True) - for key, obj in ex.tex_objs.items(): - rel = f"textures/{identity(key)}.png" - if rel not in written or not (out / rel).is_file(): - obj.read().image.save(out / rel, format="PNG") - written.add(rel) - result["textures"][key] = rel - for key, sprite in ex.sprite_recs.items(): - texture_key = sprite.get("textureRef") - if texture_key is None: - texture_key = f"{sprite['tex'].assets_file.name}:{sprite['tex'].path_id}" - result["sprites"][key] = {**{k: v for k, v in sprite.items() if k != "tex"}, "textureRef": texture_key} - for key, image in getattr(ex, "decoded_sprites", {}).items(): - rel = f"textures/{identity(key)}.png" - if rel not in written or not (out / rel).is_file(): - image.save(out / rel, format="PNG") - written.add(rel) - result["textures"][key] = rel - for obj in ex._objects.values(): - if obj.type.name != "Font": - continue - font = obj.read() - if font.m_FontData: - rel = f"fonts/{object_id(obj)}.ttf" - (out / "fonts").mkdir(exist_ok=True) - if rel not in written or not (out / rel).is_file(): - (out / rel).write_bytes(bytes(font.m_FontData)) - written.add(rel) - result["fonts"][font.m_Name] = rel - return result - - -def _record(out, ident, name, kind, document, resource_table, directory, **extra): - rel = f"{directory}/{ident}.json" - write_json( - out / rel, - { - "schema": 1, - "format": "ournotes-ui-pack", - "resourceBase": "../", - "document": document, - "resources": resource_table, - }, - ) - nodes = document.get("nodes", []) - files = { - rel, - *resource_table.get("textures", {}).values(), - *resource_table.get("fonts", {}).values(), - *resource_table.get("fontMetricsByAsset", {}).values(), - } - if resource_table.get("fontMetrics"): - files.add(resource_table["fontMetrics"]) + for oid in doc["prefabs"]: + if oid not in doc["roots"]: + embedded.setdefault(oid, {**rows[oid], "manifest": manifest, "roots": [oid], "kind": "prefab"}) + for oid in doc["controllers"]: + controllers.setdefault(oid, {**rows[oid], "manifest": manifest}) return { - "id": ident, - "name": name, - "kind": kind, - "file": rel, - "status": "exported", - "sha256": hashlib.sha256((out / rel).read_bytes()).hexdigest(), - "node_count": len(nodes), - "classes": dict(Counter(c.get("class", c["type"]) for n in nodes for c in n["components"])), - "files": {name: hashlib.sha256(_output_file(out, name).read_bytes()).hexdigest() for name in sorted(files)}, - "runtime_verified": False, - **extra, - } - - -def _output_file(out, name): - file = (out / name).resolve() - if not file.is_relative_to(out): - raise ValueError("UI index contains a file outside its output directory") - return file - - -def _valid_record(out, record): - files = record.get("files") if record else None - if not files or record.get("status") != "exported": - return False - if record.get("file") not in files: - return False - _output_file(out, record["file"]) - for name, expected in files.items(): - file = _output_file(out, name) - if not file.is_file() or hashlib.sha256(file.read_bytes()).hexdigest() != expected: - return False - return True - - -def export_library( - cat, out, *, player=None, keys=None, prefix="EmbUI/", dependencies=True, force=False, provenance=None -): - """Incrementally export keys, their unregistered prefab roots and controllers. - - Resume verifies packs, dependency packs and every texture/font hash; failures stay explicit in index.json. - Existing unrelated entries remain present when exporting an additional key. - """ - out = Path(out).resolve() - out.mkdir(parents=True, exist_ok=True) - for directory in ("packs", "embedded", "controllers", "fonts", "textures"): - (out / directory).mkdir(exist_ok=True) - keys = sorted(set(keys if keys is not None else cat.keys(prefix))) - old = json.loads((out / "index.json").read_text(encoding="utf-8")) if (out / "index.json").is_file() else {} - provenance = provenance or { - "apk_catalog_sha256": hashlib.sha256(cat.sources().get("apk", cat.sources()["remote"])).hexdigest() + "format": LIBRARY, + "assets": assets, + "embedded": [embedded[k] for k in sorted(embedded)], + "controllers": [controllers[k] for k in sorted(controllers)], } - if old and old.get("provenance") != provenance: - raise ValueError("UI output belongs to different input data; use a separate output directory") - assets = {e["key"]: e for e in old.get("assets", [])} - embedded = {e["id"]: e for e in old.get("embedded", [])} - controllers = {e["id"]: e for e in old.get("controllers", [])} - written, processed_roots, processed_controllers = set(), set(), set() - - def exporter(): - return UIExporter( - cat, - out, - player=player, - textures="deferred", - inline_meshes=False, - stub_assets=("TMP_FontAsset", "TMP_SpriteAsset", "TMP_StyleSheet"), - ) - - def save_index(): - rows = sorted(assets.values(), key=lambda e: e["key"]) - index = { - "schema": 1, - "format": "ournotes-ui-library", - "scope": "serialized UI assets and dependency roots", - "counts": dict(Counter(e["status"] for e in rows)), - "assets": rows, - "embedded": sorted(embedded.values(), key=lambda e: e["id"]), - "controllers": sorted(controllers.values(), key=lambda e: e["id"]), - "runtime_verified": False, - "provenance": provenance, - } - write_atomic(out / "index.json", dumps(index, indent=1, ensure_ascii=False).encode("utf-8")) - return index - - for key in keys: - previous = assets.get(key) - if not force and previous is not None and _valid_record(out, previous): - refs = previous.get("dependencies", {}) - valid_deps = ( - not dependencies - or previous.get("dependency_export") - and all(_valid_record(out, embedded.get(i)) for i in refs.get("embedded", [])) - and all(_valid_record(out, controllers.get(i)) for i in refs.get("controllers", [])) - ) - if valid_deps: - continue - ident, name = identity(key), key.rsplit("/", 1)[-1] - try: - ex = exporter() - refs = {"embedded": [], "controllers": []} - document = ex.export_key(key) - env, graph = ex.load(key) - main_roots = {n["nodeId"] for n in document.get("nodes", []) if n["path"].count("/") == 0} - if dependencies and document.get("nodes"): - for pid, transform in graph.tf.items(): - if transform["m_Father"]["m_PathID"]: - continue - go_pid = transform["m_GameObject"]["m_PathID"] - root_id = identity(f"{ex._go_file[go_pid]}:{go_pid}") - if root_id in main_roots: - continue - refs["embedded"].append(root_id) - go_name = graph.go[go_pid]["m_Name"] - if root_id not in processed_roots and (force or not _valid_record(out, embedded.get(root_id))): - doc = {"key": go_name, "nodes": ex.hierarchy(env, graph, pid)} - embedded[root_id] = _record( - out, - root_id, - go_name, - "prefab", - doc, - resources(ex, doc, out, written), - "embedded", - source_key=key, - ) - processed_roots.add(root_id) - if dependencies: - for obj in env.objects: - if obj.type.name != "AnimatorController": - continue - cid = object_id(obj) - refs["controllers"].append(cid) - if cid in processed_controllers or not force and _valid_record(out, controllers.get(cid)): - continue - fresh = exporter() - fresh.load(key) - owned = fresh._objects[(obj.assets_file.name, obj.path_id)] - doc = fresh.controller(owned) - controllers[cid] = _record( - out, - cid, - doc["name"], - "controller", - doc, - resources(fresh, doc, out, written), - "controllers", - source_key=key, - ) - processed_controllers.add(cid) - kind = cat.entry(key)["internal_id"].rsplit(".", 1)[-1] - assets[key] = _record( - out, - ident, - name, - kind, - document, - resources(ex, document, out, written), - "packs", - key=key, - dependencies=refs, - dependency_export=dependencies, - ) - except Exception as exc: - assets[key] = { - "id": ident, - "name": name, - "key": key, - "status": "failed", - "error_type": type(exc).__name__, - "error": str(exc), - "runtime_verified": False, - } - save_index() - return save_index() - - -def export_apk_library( - cfg, out, *, keys=None, prefix: str = "EmbUI/", limit: int | None = None, dependencies=True, force=False -) -> dict: - """The library of the APK catalog's keys `keys` (else the keys under `prefix`, the first `limit`), with the - provenance of the APK it was read from -> its index (export_library). A key the APK catalog does not have is a - ConfigError.""" - from ..source.apkset import MANIFEST_IN_APK, ApkSet, manifest_version_code, manifest_version_name - from ..source.catalog import APK_CATALOG, Catalog - from ..source.config import ConfigError - from ..source.session import Session - from ..unity.player import PlayerData - - apk = cfg.require_path("paths", "apk") - with ApkSet(apk) as source: - catalog_data = source.read(APK_CATALOG) - members = sorted((i.filename, i.CRC, i.file_size) for i in source.infolist()) - manifest = source.read(MANIFEST_IN_APK) if MANIFEST_IN_APK in source.namelist() else b"" - provenance = { - "apk_catalog_sha256": hashlib.sha256(catalog_data).hexdigest(), - "apk_members_sha256": hashlib.sha256(dumps(members).encode("utf-8")).hexdigest(), - "region": cfg.get("catalog", "region"), - "client": {"versionName": manifest_version_name(manifest), "versionCode": manifest_version_code(manifest)}, - } - cat = Catalog( - catalog_data, cfg.require_path("paths", "cache"), bundle_key=lambda: Session(cfg).bundle_key(), apk=apk - ) - keys = keys or cat.keys(prefix) - if limit is not None: - keys = keys[:limit] - unknown = sorted(set(keys) - set(cat.keys(""))) - if unknown: - raise ConfigError("UI key not in the APK catalog: " + ", ".join(unknown)) - try: - return export_library( - cat, - out, - player=PlayerData(Session(cfg).apk()), - keys=keys, - dependencies=dependencies, - force=force, - provenance=provenance, - ) - except ValueError as exc: - raise ConfigError(str(exc)) from None diff --git a/src/nnnotes/core/cache.py b/src/nnnotes/core/cache.py index e1ee210..6d98961 100644 --- a/src/nnnotes/core/cache.py +++ b/src/nnnotes/core/cache.py @@ -2,14 +2,11 @@ A bucket maps a content key to a value computed from exactly the inputs the key covers, so a hit returns what the computation would return: the extractors' outputs stay byte-identical with or without the cache. Buckets live in the -process (LRU, bounded by a byte budget, thread-safe). Buckets that hold bytes can also keep them on disk -(`configure(directory=...)`): processes of one build then share the results, and a later run with the same inputs -reuses them. Keys of the disk layer are salted with the source of the module that produces the value and the -versions of the libraries it calls, so a code or library change never reads an older result. +process (LRU, bounded by a byte budget, thread-safe). Persistent task results belong to the depot. from nnnotes.core import cache cache.configure(enabled=False) # every get misses, nothing is stored - cache.configure(memory_mb=512, directory="work/cache") + cache.configure(memory_mb=512) PNG = cache.bucket("png", salt=cache.source_salt(__file__)) k = PNG.key(data, width, height) png = PNG.get(k) @@ -30,7 +27,6 @@ from pathlib import Path from typing import Any, Protocol, TypedDict, cast -DISK_MAGIC = b"NNC1" DEFAULT_MEMORY_MB = 128 @@ -39,7 +35,6 @@ class Settings(TypedDict): enabled: bool memory: int # byte budget of each bucket - directory: Path | None # disk layer of the byte buckets closures: int # loaded bundle closures kept per thread closure_bytes: int # their bundle bytes @@ -47,7 +42,6 @@ class Settings(TypedDict): _settings: Settings = { "enabled": True, "memory": DEFAULT_MEMORY_MB << 20, - "directory": None, "closures": 4, "closure_bytes": 512 << 20, } @@ -59,20 +53,17 @@ def configure( *, enabled: bool | None = None, memory_mb: int | None = None, - directory: str | os.PathLike[str] | None = None, closures: int | None = None, closure_mb: int | None = None, ) -> Settings: - """Set the caches of this process: `enabled`; `memory_mb` (byte budget of each bucket); `directory` (disk layer - of the byte buckets; "" or None for none); `closures` / `closure_mb` (loaded bundle closures kept by + """Set the caches of this process: `enabled`; `memory_mb` (byte budget of each bucket); + `closures` / `closure_mb` (loaded bundle closures kept by unity.load_closure, per thread: count and bundle bytes). Clears the buckets. Returns the settings.""" with _lock: if enabled is not None: _settings["enabled"] = bool(enabled) if memory_mb is not None: _settings["memory"] = max(0, int(memory_mb)) << 20 - if directory is not None: - _settings["directory"] = Path(directory) if directory else None if closures is not None: _settings["closures"] = max(0, int(closures)) if closure_mb is not None: @@ -91,18 +82,18 @@ def enabled() -> bool: return _settings["enabled"] -def bucket(name: str, *, salt: str = "", disk: bool = False, max_item: int | None = None) -> Bucket: - """The process's bucket `name` (created on first use). `disk`: its values are bytes and may be kept on disk; +def bucket(name: str, *, salt: str = "", max_item: int | None = None) -> Bucket: + """The process's bucket `name` (created on first use). `max_item`: values larger than this many bytes are not kept (default: a quarter of the budget).""" with _lock: b = _buckets.get(name) if b is None: - b = _buckets[name] = Bucket(name, salt, disk, max_item) + b = _buckets[name] = Bucket(name, salt, max_item) return b def stats() -> dict[str, dict[str, int]]: - """Per bucket: items, bytes, hits, misses, disk hits, disk writes, disk write errors.""" + """Per bucket: items, bytes, hits and misses.""" return {n: b.stats() for n, b in sorted(_buckets.items())} @@ -174,12 +165,12 @@ def file_id(path: str | os.PathLike[str]) -> tuple[str, int, int]: class Bucket: """A memo cache of values by content key (see the module documentation).""" - def __init__(self, name: str, salt: str, disk: bool, max_item: int | None): - self.name, self.salt, self.disk, self._max_item = name, salt, disk, max_item + def __init__(self, name: str, salt: str, max_item: int | None): + self.name, self.salt, self._max_item = name, salt, max_item self._items: OrderedDict[str, tuple[Any, int]] = OrderedDict() # key -> (value, byte size) self._bytes = 0 self._lock = threading.Lock() - self.hits = self.misses = self.disk_hits = self.disk_writes = self.disk_errors = 0 + self.hits = self.misses = 0 def key(self, *parts: object) -> str: return key(self.name, self.salt, parts) @@ -198,32 +189,16 @@ def get(self, k: str) -> Any: self._items.move_to_end(k) self.hits += 1 return hit[0] - directory = _settings["directory"] - if self.disk and directory is not None: - data = _disk_read(self._path(directory, k)) - if data is not None: - with self._lock: - self.disk_hits += 1 - self._remember(k, data, len(data)) - return data with self._lock: self.misses += 1 return None def put(self, k: str, value: Any, size: int | None = None) -> None: - """Store `value` (bytes for a disk bucket) under `k`; `size`: its byte size (default len(value)).""" + """Store `value` under `k`; `size`: its byte size (default len(value)).""" if not _settings["enabled"]: return n = len(value) if size is None else int(size) self._remember(k, value, n) - directory = _settings["directory"] - if self.disk and directory is not None and n <= max(self._limit(), 64 << 20): - ok = _disk_write(self._path(directory, k), value) - with self._lock: - if ok: - self.disk_writes += 1 - else: - self.disk_errors += 1 def _remember(self, k: str, value: Any, n: int) -> None: if n > self._limit(): @@ -239,9 +214,6 @@ def _remember(self, k: str, value: Any, n: int) -> None: _, (_, m) = self._items.popitem(last=False) self._bytes -= m - def _path(self, directory: Path, k: str) -> Path: - return directory / self.name / k[:2] / k - def clear(self) -> None: with self._lock: self._items.clear() @@ -254,23 +226,9 @@ def stats(self) -> dict[str, int]: "bytes": self._bytes, "hits": self.hits, "misses": self.misses, - "diskHits": self.disk_hits, - "diskWrites": self.disk_writes, - "diskErrors": self.disk_errors, } -def _disk_read(p: Path) -> bytes | None: - try: - raw = p.read_bytes() - except OSError: - return None - if len(raw) < 12 or raw[:4] != DISK_MAGIC: - return None - (n,) = struct.unpack_from(" Path: """A new temporary file name next to `dst` for writing it atomically: short whatever `dst`'s name (a long name plus a suffix would pass the file system's name limit), random, so unique across processes and threads.""" @@ -293,20 +251,9 @@ def write_atomic(dst: Path, data: bytes) -> None: raise -def _disk_write(p: Path, data: bytes) -> bool: - """Write a disk entry atomically; False when the file system refused (counted as diskErrors: a cache that - cannot write only misses later, the result is the same).""" - try: - p.parent.mkdir(parents=True, exist_ok=True) - write_atomic(p, DISK_MAGIC + struct.pack(" bytes: - """One bytes value from a header and blobs (for disk buckets whose value is several files).""" + """One bytes value from a header and blobs.""" out = [struct.pack(" Any: + """`v` with every NaN replaced by {"$float": "nan"}; containers are copied only where something changed.""" + if isinstance(v, float): + return dict(NAN) if v != v else v + if isinstance(v, dict): + d = cast(dict[Any, Any], v) + out: dict[Any, Any] | None = None + for k, x in d.items(): + y = _tag_nan(x) + if y is not x: + if out is None: + out = dict(d) + out[k] = y + return d if out is None else out + if isinstance(v, (list, tuple)): + s = cast(list[Any] | tuple[Any, ...], v) + items: list[Any] | None = None + for i, x in enumerate(s): + y = _tag_nan(x) + if y is not x: + if items is None: + items = list(s) + items[i] = y + return s if items is None else items + return v + + +def _text(obj: Any, **kw: Any) -> str: + try: + return jsonio.dumps(obj, **kw) + except ValueError: + tagged = _tag_nan(obj) + if tagged is obj: + raise # not a NaN: the writer's own error + return jsonio.dumps(tagged, **kw) + + +def dumps(obj: Any, *, sort_keys: bool = True) -> str: + """The canon/1 text of a document: keys sorted (`sort_keys=False` keeps the document's order, for object + documents), indent 1, non-ASCII as is, one trailing newline.""" + return _text(obj, indent=1, ensure_ascii=False, sort_keys=sort_keys) + "\n" + + +def encode(obj: Any, *, sort_keys: bool = True) -> bytes: + """dumps() as UTF-8 bytes.""" + return dumps(obj, sort_keys=sort_keys).encode("utf-8") + + +def encode_chunks(obj: Any, size: int = 1 << 20) -> Iterator[bytes]: + """encode(obj) as byte strings of about `size` bytes, made as they are written (a large document's text is + never whole in memory, nor the encoder's list of its pieces). Raises ValueError, possibly after some chunks, + for a document with a non-finite number: encode() writes those.""" + enc = json.JSONEncoder(indent=1, ensure_ascii=False, sort_keys=True, allow_nan=False) + buf: list[str] = [] + n = 0 + for piece in enc.iterencode(obj): + buf.append(piece) + n += len(piece) + if n >= size: + yield "".join(buf).encode("utf-8") + buf, n = [], 0 + buf.append("\n") + yield "".join(buf).encode("utf-8") + + +def _untag(d: dict[str, Any]) -> Any: + return float("nan") if d == NAN else d + + +def loads(text: str | bytes | bytearray) -> Any: + """A canon/1 (or any JSON) document: {"$float": "nan"} reads back as NaN, 1e999 as infinity.""" + if isinstance(text, (bytes, bytearray)): + text = bytes(text).decode("utf-8") + return json.loads(text, object_hook=_untag) + + +def key_text(value: Any) -> str: + """The compact canonical text a key hash is taken of: keys sorted, ASCII only, no whitespace.""" + return _text(value, separators=(",", ":"), ensure_ascii=True, sort_keys=True) + + +def digest(value: Any) -> str: + """The key hash of a JSON value (sha256 hex of key_text).""" + return hashlib.sha256(key_text(value).encode("ascii")).hexdigest() + + +def sha256(data: bytes | bytearray | memoryview) -> str: + """The content id of bytes.""" + return hashlib.sha256(data).hexdigest() + + +def is_sha256(s: object) -> bool: + return isinstance(s, str) and _SHA256.fullmatch(s) is not None diff --git a/src/nnnotes/core/downloads.py b/src/nnnotes/core/downloads.py new file mode 100644 index 0000000..b1f5150 --- /dev/null +++ b/src/nnnotes/core/downloads.py @@ -0,0 +1,268 @@ +"""Budgeted depot downloads, with finished-batch release and command leases protecting active readers. + + /downloads/ the game files as downloaded (source.catalog) + /leases/.json one per running command: {"host", "pid", "started"} + /leases/.exclusive held by a command that must be the only one (trimming, `depot gc`) + +A command holds a lease while it runs (`lease`). A successful object conversion batch may release its recoverable +bundle downloads toward the budget (`release`) while it is the only command. When the command ends and no other +lease is live (a lease of this host whose +process runs, or of another host younger than a day), the files under the `bundles/` and `raw/` directories of +downloads/ are removed, least recently used first (a file's modification time: the catalog sets it when it hands +the file out), until they fit the budget. Catalog files stay. A removed file is downloaded again when a command needs +it: tasks name their inputs by content and know where to fetch them. + +A command that must run alone (`exclusive`) creates `.exclusive` before it looks for other leases, and a command +that starts writes its lease before it waits for `.exclusive` to go: either the first sees the new lease and does +nothing, or the new command touches nothing until the first is done. +""" + +from __future__ import annotations + +import ctypes +import json +import os +import re +import secrets +import socket +import sys +import time +from collections.abc import Generator, Iterable, Iterator +from contextlib import contextmanager +from pathlib import Path +from typing import Any + +__all__ = ["evictable", "exclusive", "lease", "parse_budget", "release", "trim"] + +FOREIGN_LEASE_SECONDS = 24 * 3600 # a lease of another host counts as live this long +EXCLUSIVE = ".exclusive" +TORN_SECONDS = 60 # an .exclusive without a complete record (its writer stopped mid-write) counts as live this long +_UNITS = {"": 1, "b": 1, "kb": 1000, "mb": 1000**2, "gb": 1000**3, "tb": 1000**4} +_UNITS.update({"kib": 1 << 10, "mib": 1 << 20, "gib": 1 << 30, "tib": 1 << 40}) +_SIZE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([a-z]*)\s*$", re.IGNORECASE) + + +def parse_budget(text: str | None) -> int | None: + """The size a budget text names: bytes (`0`, `500MB`, `20GiB`, `1.5 TB`), None for `all` (no limit); empty: 0.""" + if text is None or not text.strip(): + return 0 + if text.strip().lower() == "all": + return None + m = _SIZE.match(text) + unit = m.group(2).lower() if m else None + if m is None or unit not in _UNITS: + raise ValueError(f"{text!r} is not a size (e.g. 0, 500MB, 20GiB) or all") + return int(float(m.group(1)) * _UNITS[unit]) + + +def touch(path: Path) -> None: + """Mark a downloaded file as used now (its modification time).""" + try: + os.utime(path) + except OSError: + pass + + +def evictable(downloads: Path) -> Iterator[Path]: + """The bundles and raw files of a downloads directory: the files below a `bundles/` or `raw/` directory.""" + if not downloads.is_dir(): + return + for p in downloads.rglob("*"): + if p.is_file() and not p.name.endswith(".part"): + if {"bundles", "raw"} & set(p.relative_to(downloads).parts[:-1]): + yield p + + +def _files(paths: Iterable[Path]) -> list[tuple[int, int, Path]]: + out: list[tuple[int, int, Path]] = [] + for path in paths: + try: + stat = path.stat() + except OSError: + continue + out.append((stat.st_mtime_ns, stat.st_size, path)) + return out + + +def _remove(files: list[tuple[int, int, Path]], needed: int, dry_run: bool) -> tuple[list[Path], int]: + removed: list[Path] = [] + removed_bytes = 0 + for _, size, path in sorted(files, key=lambda f: (f[0], str(f[2]))): + if removed_bytes >= needed: + break + if not dry_run: + try: + path.unlink() + except OSError: + continue + removed.append(path) + removed_bytes += size + return removed, removed_bytes + + +def trim(downloads: Path, budget: int | None, *, dry_run: bool = False) -> dict[str, int]: + """Remove the least recently used bundles and raw files until the rest fit `budget` bytes (None: nothing). + -> {"kept", "removed", "bytesKept", "bytesRemoved"}.""" + files = _files(evictable(downloads)) + total = sum(f[1] for f in files) + removed, removed_bytes = _remove(files, 0 if budget is None else total - budget, dry_run) + if not dry_run and removed: + _prune_dirs(downloads) + return { + "kept": len(files) - len(removed), + "removed": len(removed), + "bytesKept": total - removed_bytes, + "bytesRemoved": removed_bytes, + } + + +def release(downloads: Path, ready: Iterable[Path], budget: int | None) -> dict[str, int]: + """Trim only finished downloads, in LRU order, toward the cache budget. The caller holds an exclusive lease. + + Active files remain protected even when they exceed the budget. A zero budget examines only finished files. + Paths must resolve below a bundles/ or raw/ directory within downloads; partial files are retained. + """ + if budget is None: + return {"removed": 0, "bytesRemoved": 0} + root = downloads.resolve() + paths: set[Path] = set() + for path in ready: + path = path.resolve() + if not path.is_relative_to(root) or not {"bundles", "raw"} & set(path.relative_to(root).parts[:-1]): + raise ValueError("released file must be a bundle or raw file within the downloads directory") + if path.is_file() and not path.name.endswith(".part"): + paths.add(path) + files = _files(paths) + total = sum(f[1] for f in files) if budget == 0 else sum(f[1] for f in _files(evictable(root))) + removed, size = _remove(files, total - budget, False) + for path in removed: + parent = path.parent + while parent != root: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + return {"removed": len(removed), "bytesRemoved": size} + + +def _prune_dirs(root: Path) -> None: + for d in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True): + try: + d.rmdir() # only an empty directory + except OSError: + pass + + +def _alive(pid: int) -> bool: + """Whether a process of this host runs (an unknown answer counts as running).""" + if pid <= 0: + return False + if os.name == "nt": + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # pyright: ignore[reportAttributeAccessIssue] + handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION + if not handle: + return ctypes.get_last_error() == 5 # access denied: it exists + try: + code = ctypes.c_ulong() + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return True + return code.value == 259 # STILL_ACTIVE + finally: + kernel32.CloseHandle(handle) + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _live(doc: dict[str, Any], host: str, now: float) -> bool: + if doc.get("host") == host: + return _alive(int(doc.get("pid") or 0)) + return now - float(doc.get("started") or 0) < FOREIGN_LEASE_SECONDS + + +def others_live(root: Path, own: Path | None = None) -> bool: + """Whether a lease other than `own` is live in the depot `root` (stale leases are removed on the way).""" + host, now = socket.gethostname(), time.time() + for p in sorted((root / "leases").glob("*.json")): + if own is not None and p == own: + continue + try: + doc = json.loads(p.read_bytes()) + except (OSError, ValueError): + continue + if _live(doc, host, now): + return True + p.unlink(missing_ok=True) + return False + + +def _record() -> str: + return json.dumps({"host": socket.gethostname(), "pid": os.getpid(), "started": time.time()}) + + +def _wait_alone(leases: Path, timeout: float) -> None: + """Wait at most `timeout` seconds for `.exclusive`; leave a live owner's lock intact.""" + p, host = leases / EXCLUSIVE, socket.gethostname() + deadline, announced = time.monotonic() + timeout, False + while True: + try: + data, mtime = p.read_bytes(), p.stat().st_mtime + except FileNotFoundError: + return + try: + live = _live(json.loads(data), host, time.time()) + except ValueError: # being written + live = time.time() - mtime < TORN_SECONDS + if not live: + p.unlink(missing_ok=True) + return + if time.monotonic() >= deadline: + raise TimeoutError(f"depot exclusive lease {p} is still held; retry after its owner finishes") + if not announced: + print(f"waiting for depot exclusive lease {p} (at most {timeout:g} seconds)", file=sys.stderr, flush=True) + announced = True + time.sleep(0.1) + + +@contextmanager +def exclusive(root: Path, own: Path | None) -> Generator[bool]: + """Hold `.exclusive` of the depot `root` while the block runs: True when no command but the one holding `own` + holds a lease (a command that starts meanwhile waits for the block to end); False when another one does.""" + lock = root / "leases" / EXCLUSIVE + lock.parent.mkdir(parents=True, exist_ok=True) + try: + with open(lock, "x", encoding="utf-8") as f: + f.write(_record()) + except FileExistsError: # another command holds it + yield False + return + try: + yield not others_live(root, own) + finally: + lock.unlink(missing_ok=True) + + +@contextmanager +def lease(root: Path, budget: int | None, *, wait_timeout: float = 30) -> Generator[Path]: + """Hold a lease on the depot `root` while the block runs; at its end, when no other lease is live, trim the + downloads to `budget` (None: keep them all).""" + d = root / "leases" + d.mkdir(parents=True, exist_ok=True) + own = d / f"{socket.gethostname()}-{os.getpid()}-{secrets.token_hex(4)}.json" + own.write_text(_record(), encoding="utf-8") + try: + _wait_alone(d, wait_timeout) + yield own + finally: + try: + if budget is not None: + with exclusive(root, own) as alone: + if alone: + trim(root / "downloads", budget) + finally: + own.unlink(missing_ok=True) diff --git a/src/nnnotes/core/media.py b/src/nnnotes/core/media.py new file mode 100644 index 0000000..5e96322 --- /dev/null +++ b/src/nnnotes/core/media.py @@ -0,0 +1,34 @@ +"""Media types of file extensions.""" + +from __future__ import annotations + +__all__ = ["MEDIA_TYPES", "media_type"] + +MEDIA_TYPES = { + "png": "image/png", + "astc": "image/astc", + "jpg": "image/jpeg", + "json": "application/json", + "glb": "model/gltf-binary", + "flac": "audio/flac", + "wav": "audio/wav", + "ogg": "audio/ogg", + "mkv": "video/x-matroska", + "ivf": "video/x-ivf", + "adx": "audio/x-adx", + "ttf": "font/ttf", + "otf": "font/otf", + "ttc": "font/collection", + "woff": "font/woff", + "woff2": "font/woff2", + "txt": "text/plain", + "xml": "application/xml", + "csv": "text/csv", + "bin": "application/octet-stream", + "bytes": "application/octet-stream", +} + + +def media_type(ext: str) -> str: + """The media type of a file extension (case-insensitive); application/octet-stream when unknown.""" + return MEDIA_TYPES.get(ext.lower(), "application/octet-stream") diff --git a/src/nnnotes/core/versions.py b/src/nnnotes/core/versions.py new file mode 100644 index 0000000..26eeb86 --- /dev/null +++ b/src/nnnotes/core/versions.py @@ -0,0 +1,38 @@ +"""Native codec identities used by content-producing tasks.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from functools import cache +from typing import cast + +PILLOW_ZLIB = "Pillow.zlib" +_PROBE = ( + "import json,sys; sys.path=json.loads(sys.argv[1]); from PIL import features; " + "print(json.dumps([features.version('zlib'), " + "features.version_feature('zlib_ng') if 'zlib_ng' in features.get_supported_features() else None]))" +) + + +@cache +def pillow_zlib_version() -> str: + """Pillow's zlib backend and version, probed without loading image codecs into the planning process.""" + result = subprocess.run( + [ + sys.executable, + "-I", + "-c", + _PROBE, + json.dumps([p for p in cast(list[object], sys.path) if isinstance(p, str)]), + ], + capture_output=True, + text=True, + check=True, + timeout=15, + ) + zlib, zlib_ng = json.loads(result.stdout) + if not isinstance(zlib, str) or not zlib: + raise RuntimeError("Pillow has no zlib codec") + return zlib if zlib_ng is None else f"{zlib}-{zlib_ng}" diff --git a/src/nnnotes/site/compress.py b/src/nnnotes/formats/compression.py similarity index 100% rename from src/nnnotes/site/compress.py rename to src/nnnotes/formats/compression.py diff --git a/src/nnnotes/formats/cri.py b/src/nnnotes/formats/cri.py index ccce5da..c4c3c9e 100644 --- a/src/nnnotes/formats/cri.py +++ b/src/nnnotes/formats/cri.py @@ -22,7 +22,7 @@ `decode` writes one file per vgmstream stream (a name repeated within the sheet, e.g. the layered waveforms of one cue, gets `_`), `cues.json` (first stream per name) and `streams.json` (every stream in vgmstream order). `decode_acb_bytes` does the same for ACB bytes -given directly (no catalog, no cache: the cri.audio stage keeps its results in the store); `split_acb` and +given directly (no catalog, no cache: the cri.audio stage keeps its results in the depot); `split_acb` and `embedded_acb` give the ACB bytes of the two in-bundle layouts from their fields. HCA streams are decrypted with the keycode from the game's own boot data (see @@ -55,7 +55,7 @@ from . import crikey from .encoders import FLAC_LEVEL, encode_args -DECODED = cache.bucket("cuesheets", salt=cache.source_salt(__file__), disk=True) +DECODED = cache.bucket("cuesheets", salt=cache.source_salt(__file__)) SHEETS = cache.bucket("acb", max_item=128 << 20) # (cue sheet, bundle closure) -> ACB / AWB bytes, layout _keys: dict[tuple, int] = {} _keys_lock = threading.Lock() diff --git a/src/nnnotes/formats/cubism.py b/src/nnnotes/formats/cubism.py new file mode 100644 index 0000000..effbcd7 --- /dev/null +++ b/src/nnnotes/formats/cubism.py @@ -0,0 +1,106 @@ +"""Cubism-for-Unity data -> the Cubism SDK interchange files: the canvas of a moc3, physics3.json, exp3.json. + +Cubism-for-Unity keeps a model's physics rig (CubismPhysicsController._rig) and its expressions +(CubismExpressionData) in its own serialized layout; these functions write them in the layout of the Cubism SDK's +JSON files (motion3.json: formats.motion). +""" + +from __future__ import annotations + +import struct + +__all__ = ["EXP_BLEND", "PHYS_TYPE", "expression_json", "moc3_canvas", "physics_json"] + +# Live2D.Cubism.Core.CubismParameterBlendMode -> exp3.json "Blend" +EXP_BLEND = {0: "Overwrite", 1: "Add", 2: "Multiply"} +# Live2D.Cubism.Framework.Physics.CubismPhysicsSourceComponent -> physics3 "Type" +PHYS_TYPE = {0: "X", 1: "Y", 2: "Angle"} + + +def moc3_canvas(moc: bytes) -> dict: + """CanvasInfo from a moc3 (section table at 0x40; entry 1 = canvas info).""" + if moc[:4] != b"MOC3": + raise ValueError("not a moc3") + canvas_off = struct.unpack_from(" dict: + """physics3.json of a CubismPhysicsController's rig (the inverse of Cubism-for-Unity's import, ToRig).""" + settings, dictionary = [], [] + n_in = n_out = n_vtx = 0 + for i, sr in enumerate(rig["SubRigs"]): + sid = f"PhysicsSetting{i + 1}" + dictionary.append({"Id": sid, "Name": sr["Name"]}) + inputs = [ + { + "Source": {"Target": "Parameter", "Id": x["SourceId"]}, + "Weight": x["Weight"], + "Type": PHYS_TYPE[x["SourceComponent"]], + "Reflect": bool(x["IsInverted"]), + } + for x in sr["Input"] + ] + outputs = [ + { + "Destination": {"Target": "Parameter", "Id": x["DestinationId"]}, + "VertexIndex": x["ParticleIndex"], + "Scale": x["AngleScale"], + "Weight": x["Weight"], + "Type": PHYS_TYPE[x["SourceComponent"]], + "Reflect": bool(x["IsInverted"]), + } + for x in sr["Output"] + ] + verts = [ + { + "Position": _vec2(p["InitialPosition"]), + "Mobility": p["Mobility"], + "Delay": p["Delay"], + "Acceleration": p["Acceleration"], + "Radius": p["Radius"], + } + for p in sr["Particles"] + ] + nz = sr["Normalization"] + settings.append( + { + "Id": sid, + "Input": inputs, + "Output": outputs, + "Vertices": verts, + "Normalization": { + "Position": {k: nz["Position"][k] for k in ("Minimum", "Default", "Maximum")}, + "Angle": {k: nz["Angle"][k] for k in ("Minimum", "Default", "Maximum")}, + }, + } + ) + n_in += len(inputs) + n_out += len(outputs) + n_vtx += len(verts) + meta = { + "PhysicsSettingCount": len(settings), + "TotalInputCount": n_in, + "TotalOutputCount": n_out, + "VertexCount": n_vtx, + "EffectiveForces": {"Gravity": _vec2(rig["Gravity"]), "Wind": _vec2(rig["Wind"])}, + "PhysicsDictionary": dictionary, + } + if rig["Fps"] > 0: # 0 = unset in physics3 terms + meta["Fps"] = rig["Fps"] + return {"Version": 3, "Meta": meta, "PhysicsSettings": settings} + + +def expression_json(tt: dict) -> dict: + """exp3.json of a CubismExpressionData's fields.""" + return { + "Type": tt["Type"], + "FadeInTime": tt["FadeInTime"], + "FadeOutTime": tt["FadeOutTime"], + "Parameters": [{"Id": p["Id"], "Value": p["Value"], "Blend": EXP_BLEND[p["Blend"]]} for p in tt["Parameters"]], + } diff --git a/src/nnnotes/formats/encoders.py b/src/nnnotes/formats/encoders.py index b74ba81..a070d56 100644 --- a/src/nnnotes/formats/encoders.py +++ b/src/nnnotes/formats/encoders.py @@ -1,11 +1,28 @@ """The ffmpeg encoder settings the audio and movie writers share. Every encode is bit-exact: the same input and ffmpeg -build give the same bytes.""" +build give the same bytes. + +WEB_AUDIO lists the audio formats for the web (`format` of the live.music product, `site build --format`): per format +the file suffix and the ffmpeg codec options (None: the FLAC as decoded). A browser's decodeAudioData decodes each +sample-aligned with the FLAC (the encoder delay and pre-skip removed); AAC-LC 160 kbit/s in MP4, the default, decodes +in Safari and iOS as well. A decoder that does not apply the MP4 edit list outputs `mp4_priming(data)` priming samples +first. +""" from __future__ import annotations +import struct +import subprocess from pathlib import Path FLAC_LEVEL = 8 # ffmpeg's FLAC compression level by default (-compression_level of the flac output) +WEB_AUDIO = { + "aac": (".m4a", ["-c:a", "aac", "-b:a", "160k"]), + "opus": (".opus", ["-c:a", "libopus", "-b:a", "128k"]), + "vorbis": (".ogg", ["-c:a", "libvorbis", "-q:a", "5"]), + "mp3": (".mp3", ["-c:a", "libmp3lame", "-b:a", "192k"]), + "flac": (".flac", None), +} +DEFAULT_AUDIO_FORMAT = "aac" def encode_args(src: Path, dst: Path, args: list[str]) -> list[str]: @@ -27,3 +44,48 @@ def encode_args(src: Path, dst: Path, args: list[str]) -> list[str]: *args, str(dst), ] + + +def web_encode(ffmpeg: str, src: Path, dst: Path, fmt: str) -> bytes: + """The bytes of the audio file `src` in the web format `fmt` (WEB_AUDIO; a bit-exact encode written to `dst`; + "flac": the file as it is).""" + codec = WEB_AUDIO[fmt][1] + if codec is None: + return Path(src).read_bytes() + r = subprocess.run([ffmpeg, *encode_args(src, dst, codec)], capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError(f"ffmpeg {fmt} failed on {src}: {r.stderr[-2000:]}") + return Path(dst).read_bytes() + + +def mp4_priming(data: bytes) -> int: + """Encoder delay of an MP4 audio track: media_time of the first edit list entry (moov/trak/edts/elst).""" + + def boxes(b, off, end): + while off + 8 <= end: + size, kind = struct.unpack(">I4s", b[off : off + 8]) + hdr = 8 + if size == 1: + size, hdr = struct.unpack(">Q", b[off + 8 : off + 16])[0], 16 + elif size == 0: + size = end - off + yield kind, off + hdr, off + size + off += size + + def find(path, off, end): + for kind, a, z in boxes(data, off, end): + if kind == path[0]: + return (a, z) if len(path) == 1 else find(path[1:], a, z) + return None + + loc = find([b"moov", b"trak", b"edts", b"elst"], 0, len(data)) + if loc is None: + return 0 + a, _ = loc + version = data[a] + count = struct.unpack(">I", data[a + 4 : a + 8])[0] + if count < 1: + return 0 + if version == 1: + return max(0, struct.unpack(">q", data[a + 16 : a + 24])[0]) + return max(0, struct.unpack(">i", data[a + 12 : a + 16])[0]) diff --git a/src/nnnotes/formats/motion.py b/src/nnnotes/formats/motion.py index 82f140e..b33888e 100644 --- a/src/nnnotes/formats/motion.py +++ b/src/nnnotes/formats/motion.py @@ -17,8 +17,6 @@ import math from pathlib import Path -from ..core.jsonio import write_json - SEG_LINEAR, SEG_BEZIER, SEG_STEPPED = 0, 1, 2 WEIGHT_IN, WEIGHT_OUT = 1, 2 # UnityEngine.WeightedMode flags @@ -56,14 +54,6 @@ def _segments(kf: list[dict]): return segs, nseg, npts, restricted -def _clip_rates(env) -> dict[str, float]: - return { - o.read_typetree()["m_Name"]: float(o.read_typetree()["m_SampleRate"]) - for o in env.objects - if o.type.name == "AnimationClip" - } - - def convert_fade(fade: dict, fps: float | None) -> dict: curves = [] total_seg = total_pts = 0 @@ -110,30 +100,3 @@ def convert_fade(fade: dict, fps: float | None) -> dict: def motion_name(fade: dict) -> str: return Path(str(fade["MotionName"])).name.removesuffix(".motion3.json") - - -def extract_motions(env, out_dir: Path) -> dict[str, str]: - """Every CubismFadeMotionData in env -> /.motion3.json. - - Returns {motionName: fileName}. Also writes `_fades.json` with the - model-level fade overrides (ModelFadeIn/OutTime, -1 = unset) the model3 - motion entries need. - """ - out_dir = Path(out_dir) - out_dir.mkdir(parents=True, exist_ok=True) - rates = _clip_rates(env) - manifest: dict[str, str] = {} - model_fades: dict[str, dict] = {} - for o in env.objects: - if o.type.name != "MonoBehaviour": - continue - if o.read().m_Script.read().m_ClassName != "CubismFadeMotionData": - continue - tt = o.read_typetree() - name = motion_name(tt) - fn = f"{name}.motion3.json" - write_json(out_dir / fn, convert_fade(tt, rates.get(name)), indent=None) - manifest[name] = fn - model_fades[name] = {"FadeInTime": tt["ModelFadeInTime"], "FadeOutTime": tt["ModelFadeOutTime"]} - write_json(out_dir / "_fades.json", model_fades, indent=None, ensure_ascii=True) - return manifest diff --git a/src/nnnotes/formats/shader.py b/src/nnnotes/formats/shader.py index aac32f8..5f82343 100644 --- a/src/nnnotes/formats/shader.py +++ b/src/nnnotes/formats/shader.py @@ -29,14 +29,10 @@ import struct from pathlib import Path -import UnityPy -from UnityPy.enums import ShaderGpuProgramType -from UnityPy.export.ShaderConverter import CompressionHelper - from ..core import cache from ..core.jsonio import dumps, write_json -DUMPS = cache.bucket("shaders", salt=cache.source_salt(__file__, "UnityPy"), disk=True) +DUMPS = cache.bucket("shaders", salt=cache.source_salt(__file__, "UnityPy")) # UnityEngine.Rendering ShaderCompilerPlatform value -> tag PLATFORM = { @@ -59,6 +55,8 @@ } TEXT_TYPES = {"GLES", "GLES3", "GLES31", "GLES31AEP", "GLCore32", "GLCore41", "GLCore43", "MetalVS", "MetalFS"} STAGES = ("progVertex", "progFragment", "progGeometry", "progHull", "progDomain") +# the programs the player draws with: GLSL ES 3.00 (WebGL2) +PLAYER_PLATFORM, PLAYER_TYPE = "gles3", "GLES3" def _safe(name: str) -> str: @@ -71,6 +69,8 @@ def _entry(arr, i): def _type_name(v: int) -> str: + from UnityPy.enums import ShaderGpuProgramType + return ShaderGpuProgramType(v).name.replace("kShaderGpuProgram", "") @@ -97,6 +97,8 @@ def _code_of(entry: bytes) -> bytes: def platform_blobs(shader) -> dict[int, list[bytes]]: + from UnityPy.export.ShaderConverter import CompressionHelper + blob = bytes(shader.compressedBlob or b"") out = {} n = min(len(shader.platforms), len(shader.compressedLengths), len(shader.decompressedLengths)) @@ -130,7 +132,7 @@ def variants(tt: dict, blobs: dict[int, list[bytes]]): n += 1 -def _parsed_summary(tt: dict) -> dict: +def parsed_summary(tt: dict) -> dict: pf = tt["m_ParsedForm"] return { "name": pf.get("m_Name"), @@ -158,7 +160,7 @@ def _parsed_summary(tt: dict) -> dict: def _render(o, tt: dict, name: str) -> tuple[str, list[dict], list[bytes]]: """(parsed summary JSON text, variant records, variant program bytes) of one Shader object.""" safe = _safe(name) - text = dumps(_parsed_summary(tt), indent=1, ensure_ascii=False, default=str) + text = dumps(parsed_summary(tt), indent=1, ensure_ascii=False, default=str) recs, codes = [], [] for plat, si, pi, stage, n, t, kws, code in variants(tt, platform_blobs(o.read())): tag = PLATFORM.get(plat, str(plat)) @@ -181,6 +183,28 @@ def _render(o, tt: dict, name: str) -> tuple[str, list[dict], list[bytes]]: return text, recs, codes +def player_roles(variants: list[dict]) -> set[str]: + """The roles of a shader's programs the player draws with (variant index records): every PLAYER_TYPE program of + PLAYER_PLATFORM.""" + return {v["role"] for v in variants if v["platform"] == PLAYER_PLATFORM and v["type"] == PLAYER_TYPE} + + +def player_program(variants: list[dict], keywords) -> dict: + """The variant record of a shader (platform, subShader, pass, type, keywords, ...) the player draws a material + with `keywords` by: the PLAYER_TYPE program of subshader 0, pass 0 whose keywords are exactly the material's + among those the programs there use. ValueError when not exactly one fits.""" + vs = [ + v + for v in variants + if v["platform"] == PLAYER_PLATFORM and v["type"] == PLAYER_TYPE and v["subShader"] == 0 and v["pass"] == 0 + ] + want = set(keywords) & {k for v in vs for k in v["keywords"]} + hits = [v for v in vs if set(v["keywords"]) == want] + if len(hits) != 1: + raise ValueError(f"{len(hits)} {PLAYER_TYPE} programs with the keywords {sorted(want)}") + return hits[0] + + def _dump_key(o) -> str: st = o.serialized_type type_hash = bytes(st.old_type_hash) if st is not None and st.old_type_hash else b"" @@ -239,6 +263,8 @@ def write_index(index: list, out_dir: Path) -> dict: def dump(bundles: list, out_dir: Path) -> dict: + import UnityPy + index: list = [] for bpath in bundles: env = UnityPy.load(str(bpath)) diff --git a/src/nnnotes/musicdata/__init__.py b/src/nnnotes/musicdata/__init__.py index 53e6fce..74cf1c5 100644 --- a/src/nnnotes/musicdata/__init__.py +++ b/src/nnnotes/musicdata/__init__.py @@ -244,16 +244,12 @@ def jacket_webp(image, size: int = JACKET_SIZE) -> bytes: def catalog_jacket(cat, size: int = JACKET_SIZE) -> Callable[[str], bytes]: """jacket(name) for a catalog: the WebP bytes (jacket_webp) of the Texture2D `Image/Jacket/`.""" - import tempfile - - from ..unity.export import Exporter - - ex = Exporter(cat, Path(tempfile.gettempdir()), textures="deferred") + from ..unity.reader import container_asset, load_closure def jacket(name: str) -> bytes: key = JACKET_KEY.format(jacket=name) try: - o = ex.key_object(key) + o = container_asset(load_closure(cat.fetch_key(key)), cat.entry(key)["internal_id"]) except KeyError: raise MusicDataError(f"jacket {name}: no asset {key}") from None except Exception as e: diff --git a/src/nnnotes/musicdata/deckdata.py b/src/nnnotes/musicdata/deckdata.py index c714947..a6f903e 100644 --- a/src/nnnotes/musicdata/deckdata.py +++ b/src/nnnotes/musicdata/deckdata.py @@ -452,28 +452,28 @@ def apk_client(apk) -> dict: return {"versionName": manifest_version_name(data), "versionCode": manifest_version_code(data)} -def catalog_info(cat, store_root=None) -> dict: +def catalog_info(cat, depot_root=None) -> dict: """{resourceVersion, sha256} of a catalog's remote catalog file (resource_version).""" sha = hashlib.sha256(cat.sources()["remote"]).hexdigest() source = getattr(cat, "source", None) return { "resourceVersion": source.version if source - else (getattr(cat, "resource_version", None) or resource_version(store_root, sha)), + else (getattr(cat, "resource_version", None) or resource_version(depot_root, sha)), "sha256": sha, **({"resourceHash": source.hash} if source else {}), } -def resource_version(store_root, remote_sha: str) -> str | None: - """The resource version recorded for the remote catalog `remote_sha` by the catalog versions of a store +def resource_version(depot_root, remote_sha: str) -> str | None: + """The resource version recorded for the remote catalog `remote_sha` by the catalog versions of a depot (the latest import that has one), None when none is recorded. Read only.""" - if store_root is None: + if depot_root is None: return None from ..pipeline.catalogdb import CatalogDB try: - versions = CatalogDB(store_root).versions() + versions = CatalogDB(depot_root).versions() except (OSError, ValueError): return None for v in reversed(versions): diff --git a/src/nnnotes/pipeline/__init__.py b/src/nnnotes/pipeline/__init__.py index 261d5d6..4c7284d 100644 --- a/src/nnnotes/pipeline/__init__.py +++ b/src/nnnotes/pipeline/__init__.py @@ -1 +1 @@ -"""The content-addressed export pipeline: stages, tasks, the store, layouts and the atoms.""" +"""The content-addressed export pipeline: stages, tasks, the depot, layouts and the atoms.""" diff --git a/src/nnnotes/pipeline/assemble.py b/src/nnnotes/pipeline/assemble.py new file mode 100644 index 0000000..f48ca4d --- /dev/null +++ b/src/nnnotes/pipeline/assemble.py @@ -0,0 +1,644 @@ +"""Product stages: products assembled from the unity.export results of the objects they reach, never from a bundle. + +A product (a Live2D model, a story, a live, ...) is a stage whose task per subject starts from root objects: the +objects of the catalog keys `keys(subject)` gives, of the Resources.Load paths `resources(subject)` gives, of the +built-in selectors `builtins(subject)` gives (objects of the APK's built-in files by class or script class) and the +objects `root_objects(subject)` names (all read from the planning facts alone, so a command can ask for the units its +products need before anything runs: `request`). The objects it reaches are those of the units holding its roots and +every unit an item of a reached unit references (contract.item `refs`; an atlas reached through a reference does not +reach the sprites it packs, units.PACKED), through the unity.export results; a sprite whose atlas is in another +bundle has its image from sprite.crop. A reached unit that the request did not export is an error of the request; a +referenced object that no census holds is listed as missing. A referenced object of a fenced bundle (units.fences) that +the walk does not reach is listed as fenced. + +A task's inputs are the object table of what it reaches ("objects", nnnotes.objects/1: every object with its class, +name, item status and files, the contained ones with the object holding them, the root objects of each of its keys, +and the font objects the fence cuts off as "fenced"), +the APK files the stage names in `APK` ("apk:", from the fact "apk"), the cue sheets of the catalog +keys `cue_sheets(subject)` gives, the movies of the catalog keys `movies(subject)` gives and what `more_inputs` adds (a +master record as "master": master_input; parameters' data); it depends on the unity.export and sprite.crop tasks it +read and the cri.audio and cri.movie tasks of its cue sheets and movies. A cue sheet is the ACB the key loads, a movie +the USM: held in a bundle (the `acb` / `usm` file of an object of the key) or a raw file of the catalog (a dependency +of the key that cri.audio / cri.movie decoded); the inputs are that file ("acb:" / "usm:") and, in "sheets" +/ "movies", the files the stage made of it ({key: {acb or usm, status, reason?, files}}; a movie's files also carry +their facts). + +`assemble` makes the product's own files from the table, the bytes of its objects, its cue sheets (Sheet) and movies +(Movie); a file may be content the depot already holds (Stored: a decoded waveform, a movie), recorded without reading +it again. The result holds them as "#", the table of the objects they use as "#objects", and one +item for the subject. A product layout (layout.product_entries) places the files and the objects' files and writes the +product's manifest. +""" + +from __future__ import annotations + +from collections import OrderedDict +from dataclasses import dataclass, field + +from ..unity.atoms import Unsupported +from . import contract, units +from .contract import Cost, Input +from .layout import PRODUCT_TABLE, escape_segment +from .orchestrate import SEVERITY +from .stages import Output, Stage + +EXPORT, CROP, AUDIO, MOVIE = "unity.export", "sprite.crop", "cri.audio", "cri.movie" +ADDRESSES = units.ADDRESSES +TABLE_KIND = "product.objects" +SHEETS_INPUT, MOVIES_INPUT = "sheets", "movies" +MASTER_INPUT = "master" + + +@dataclass(frozen=True) +class Decodes: + """How a product reads the results of a CRI stage: the file role of the content in the object table and of its + input (":"), the stage that decodes it, the input that lists the stage's files, what the content is + called and whether the files carry their facts.""" + + role: str + stage: str + input: str + what: str + facts: bool = False + + +SHEETS = Decodes("acb", AUDIO, SHEETS_INPUT, "cue sheet") +MOVIES = Decodes("usm", MOVIE, MOVIES_INPUT, "movie", facts=True) + + +def master_input(env, record: dict) -> Input: + """The input "master" of a product task: the master record `record` (the rows it reads), stored in the depot.""" + body = contract.encode(record) + return Input(MASTER_INPUT, env.depot.put(body), len(body), None, ({"kind": "depot"},)) + + +def request(stages: dict, products: dict, env) -> dict: + """The fact "roots" (pipeline.units) of a request for `products` {stage name: subjects}: the catalog keys, the + resource paths, the built-in selectors and the root objects of every subject, sorted.""" + groups = {name: set() for name in ("keys", "resources", "builtins", "objects")} + font_roots = {name: set() for name in groups} + for name, subjects in sorted(products.items()): + stage = stages[name] + for s in subjects: + for kind, method in ( + ("keys", "keys"), + ("resources", "resources"), + ("builtins", "builtins"), + ("objects", "root_objects"), + ): + values = getattr(stage, method)(s, env) + groups[kind].update(values) + if stage.INCLUDE_FONTS: + font_roots[kind].update(values) + result: dict = {kind: sorted(values) for kind, values in groups.items()} + if any(font_roots.values()): + result["font_roots"] = {kind: sorted(values) for kind, values in font_roots.items()} + return result + + +def _entry(item: dict, arts: dict) -> dict: + """The object table entry of a unity.export or sprite.crop item.""" + e: dict = {"status": item["status"]} + cls, name, files = item.get("class"), None, {} + for aid in item.get("artifacts", ()): + a = arts[aid] + c = a["content"] + files[contract.parse_artifact_id(aid)[1]] = {"sha256": c["sha256"], "size": c["size"], "ext": c["ext"]} + obj = (a.get("provenance") or {}).get("object") or {} + cls, name = cls or obj.get("class"), name or obj.get("name") + if cls: + e["class"] = cls + if name: + e["name"] = name + if files: + e["files"] = files + if "in" in item: + e["in"] = contract.parse_artifact_id(item["in"])[0] + if "reason" in item: + e["reason"] = item["reason"]["code"] + return e + + +def _merge(e: dict, other: dict) -> None: + """Add the files of another item of the same object; the status becomes the more severe.""" + e.setdefault("files", {}).update(other.get("files", {})) + if SEVERITY.index(other["status"]) < SEVERITY.index(e["status"]): + e["status"] = other["status"] + if "reason" in other: + e["reason"] = other["reason"] + + +@dataclass +class Reach: + """What a product reaches: its object table (nnnotes.objects/1), the tasks whose results it read, the bytes of + its objects' files.""" + + table: dict + read: list = field(default_factory=list) + size: int = 0 + + +class _Results: + """The items and artifact records of the results read, by result key (a result never changes).""" + + def __init__(self, size: int = 4096): + self.size, self._docs = size, OrderedDict() + + def get(self, env, tid: str) -> tuple[list, dict]: + key = env.key(tid) + doc = self._docs.get(key) + if doc is None: + res = env.result(tid) + doc = self._docs[key] = (res["items"], {a["id"]: a for a in res["artifacts"]}) + while len(self._docs) > self.size: + self._docs.popitem(last=False) + else: + self._docs.move_to_end(key) + return doc + + +def reach(env, roots, results: _Results | None = None, keys: dict | None = None, *, fonts=False) -> Reach: + """The objects reachable from the object ids `roots` (module documentation); `keys` {catalog key: its object ids, + all among the roots} goes into the table as it is.""" + results = results or _Results() + table = units.table(env) + todo, seen, missing = [], set(), set() + for o in roots: + s = table.subject_of(o) + if s is None: + missing.add(o) + else: + todo.append(s) + start = set(todo) + objects: dict[str, dict] = {} + read = [] + crops: dict[str, list] = {} # atlas object id -> sprite object ids + blocked: dict[str, dict] = {} # objects a reached object references across the font fence: {id: class, name} + while todo: + s = todo.pop() + if s in seen: + continue + seen.add(s) + table.unit(s) # ValueError: a unit the request does not export + tid = contract.task_id(EXPORT, s) + items, arts = results.get(env, tid) + read.append(tid) + for it in items: + oid = it["object"] + objects[oid] = _entry(it, arts) + for aid in it.get("artifacts", ()): + facts = arts[aid]["semantics"].get("facts") or {} + if facts.get("crop") == CROP: + crops.setdefault(facts["atlas"], []).append(oid) + for r in it.get("refs", ()): + t = table.subject_of(r) + if t is None: + missing.add(r) + elif not fonts and table.fences(s, t): + blocked[r] = table.info[r] + elif t not in seen and table.reaches(s, t, s in start, fonts=fonts): + todo.append(t) + for atlas, sprites in sorted(crops.items()): + s = table.subject_of(atlas) + # a sprite of an unfenced bundle takes no image from a fenced atlas the walk does not reach + cut = { + o + for o in sprites + if not fonts + and s is not None + and s not in seen + and (src := table.subject_of(o)) is not None + and table.fences(src, s) + } + if cut: + blocked[atlas] = table.info[atlas] + sprites = [o for o in sprites if o not in cut] + if not sprites: + continue + tid = contract.task_id(CROP, s) if s is not None else None + if tid is None or not env.known(tid): # the atlas is not exported: its sprites have no image + missing.add(atlas) + continue + items, arts = results.get(env, tid) + read.append(tid) + made = {it["object"]: it for it in items} + for oid in sprites: + if oid in made: + _merge(objects[oid], _entry(made[oid], arts)) + doc = { + "schema": contract.OBJECTS, + "roots": sorted(set(roots)), + "keys": {k: sorted(v) for k, v in sorted((keys or {}).items())}, + "objects": dict(sorted(objects.items())), + "fenced": {r: v for r, v in sorted(blocked.items()) if r not in objects}, + "missing": sorted(missing), + } + size = sum(f["size"] for e in objects.values() for f in e.get("files", {}).values()) + return Reach(doc, sorted(read), size) + + +@dataclass(frozen=True) +class Stored: + """A product file that is content the depot holds: its content id and size.""" + + sha256: str + size: int + + +class _Decoded: + """The content of a catalog key a CRI stage decoded for a product task, and the files the stage made of it.""" + + WHAT = "" + REASON = "" + + def __init__(self, key: str, entry: dict, source: Input, depot): + self.key, self.entry, self._source, self.depot = key, entry, source, depot + + def _check(self) -> None: + if self.entry["status"] != "exported": + reason = self.entry.get("reason") or self.REASON + raise Unsupported(reason, f"{self.WHAT} {self.key} was not decoded ({self.entry['status']})") + + def _file(self, name: str) -> dict: + self._check() + f = self.entry["files"].get(name) + if f is None: + raise KeyError(f"{self.WHAT} {self.key} has no file {name!r}") + return f + + def file(self, name: str) -> Stored: + f = self._file(name) + return Stored(f["sha256"], f["size"]) + + def bytes(self, name: str) -> bytes: + return self.depot.read(self.file(name).sha256) + + +class Sheet(_Decoded): + """A cue sheet of a product task: its ACB and the files cri.audio made of it (one per stream, `cues.json`, + `streams.json`).""" + + WHAT, REASON = "cue sheet", "unsupported.cri.sheet" + + def acb(self) -> bytes: + return self.depot.input_bytes(self._source) + + def streams(self) -> list[dict]: + """streams.json: every stream in decoder order ({stream, name, file, sampleRate, channels, samples, ...}).""" + return contract.loads(self.bytes("streams.json")) + + +class Movie(_Decoded): + """A movie of a product task: its USM and the files cri.movie made of it (its streams, `movie.`), each + with its facts.""" + + WHAT, REASON = "movie", "unsupported.cri.movie" + + def _file(self, name: str) -> dict: + """A movie whose subtitles cannot be read is `unsupported` and keeps its other files: a file it made is + there.""" + f = self.entry["files"].get(name) + return f if f is not None else super()._file(name) + + def facts(self, name: str) -> dict: + """The facts cri.movie recorded for its file `name`.""" + return self._file(name).get("facts") or {} + + +class Objects: + """The object table of a product task, the bytes of its objects' files (from the depot) and its cue sheets.""" + + def __init__(self, doc: dict, depot, sheets: dict | None = None, movies: dict | None = None): + self.doc, self.depot = doc, depot + self.roots: list[str] = doc["roots"] + self._keys: dict[str, list[str]] = doc["keys"] + self._objects: dict = doc["objects"] + self._fenced: dict = doc["fenced"] + self._json: dict = {} + self._sheets: dict[str, Sheet] = sheets or {} + self._movies: dict[str, Movie] = movies or {} + + def sheet(self, key: str) -> Sheet: + """The cue sheet of the catalog key `key` (one of the stage's cue_sheets).""" + try: + return self._sheets[key] + except KeyError: + raise KeyError(f"cue sheet {key} is not an input of the product") from None + + def movie(self, key: str) -> Movie: + """The movie of the catalog key `key` (one of the stage's movies).""" + try: + return self._movies[key] + except KeyError: + raise KeyError(f"movie {key} is not an input of the product") from None + + def key(self, key: str) -> list[str]: + """The root objects of the catalog key `key` (one of the stage's keys), sorted.""" + try: + return self._keys[key] + except KeyError: + raise KeyError(f"{key} is not a key of the product") from None + + def keys(self) -> list[str]: + """The catalog keys of the product, sorted (each with its root objects, possibly none).""" + return sorted(self._keys) + + def __contains__(self, oid: str) -> bool: + return oid in self._objects + + def ids(self, cls: str | None = None) -> list[str]: + """The object ids of the table (of class `cls`), sorted.""" + return [o for o, e in self._objects.items() if cls is None or e.get("class") == cls] + + def info(self, oid: str) -> dict: + try: + return self._objects[oid] + except KeyError: + raise KeyError(f"object {oid} is not in the product's object table") from None + + def fenced(self, oid: str) -> dict | None: + """The class and name of the object `oid` when a reached object references it across the font fence (None: + no reached object does).""" + return self._fenced.get(oid) + + def bytes(self, oid: str, role: str) -> bytes: + files = self.info(oid).get("files") or {} + if role not in files: + raise KeyError(f"object {oid} has no {role!r} file") + return self.depot.read(files[role]["sha256"]) + + def json(self, oid: str, role: str = "json"): + """The decoded JSON of a file of an object (kept for the task).""" + k = (oid, role) + if k not in self._json: + self._json[k] = contract.loads(self.bytes(oid, role)) + return self._json[k] + + +@dataclass +class Assembly: + """What `assemble` makes: the product's files {path: (bytes or Stored, extension, kind)} and what of the object + table they use: `objects` the object ids (None: every object of the table), `roles` {object id: the roles of its + files the product keeps} for an object of which it uses some files only (a shader's programs for one graphics + API). `table` optionally supplies the table of the retained roots when the inputs also include objects read only + during assembly. The product's object table, and so its manifest, holds only those.""" + + files: dict + objects: set | None = None + roles: dict = field(default_factory=dict) + table: dict | None = None + + +def _prune(tid: str, objects: dict, made: Assembly) -> dict: + """The entries of an object table an Assembly keeps.""" + keep = set(objects) if made.objects is None else set(made.objects) + unknown = sorted((keep | set(made.roles)) - set(objects)) + if unknown: + raise ValueError(f"{tid}: objects not in the table: {', '.join(unknown[:5])}") + out = {} + for o, e in objects.items(): + if o not in keep: + continue + roles = made.roles.get(o) + if roles is not None: + files = e.get("files") or {} + lacking = sorted(set(roles) - set(files)) + if lacking: + raise ValueError(f"{tid}: object {o} has no {', '.join(lacking)} file") + e = dict(e, files={r: f for r, f in files.items() if r in roles}) + out[o] = e + return out + + +class ProductStage(Stage): + """Base of the product stages (module documentation). Subclasses set `name`, `version` and `KIND`, and define + `keys`, `resources`, `builtins` and/or `root_objects` and `assemble`; `subjects` defaults to the subjects of the + fact "products". A stage that reads master tables names them in `MASTER` (its inputs come from the fact + "master"), one that reads files of the APK names them in `APK`.""" + + after = (ADDRESSES, EXPORT, CROP, AUDIO, MOVIE) + KIND = "product" + INCLUDE_FONTS = False + MASTER: tuple[str, ...] = () + APK: tuple[str, ...] = () + + def __init__(self): + self._memo: dict = {} + self._results = _Results() + + def __getstate__(self): + return {k: v for k, v in self.__dict__.items() if k not in ("_memo", "_results")} + + def __setstate__(self, state): + self.__dict__.update(state) + self._memo, self._results = {}, _Results() + + # ---------------------------------------------------------------- what a subclass defines + def subjects(self, env) -> list[str]: + return sorted((env.facts.get("products") or {}).get(self.name, ())) + + def select(self, keys: list[str], env) -> list[str]: + """The subjects among the catalog keys `keys` (those a selection's bundles hold), sorted (from the facts + alone).""" + return [] + + def keys(self, subject: str, env) -> list[str]: + """The catalog keys whose objects are roots of `subject` (from the facts alone).""" + return [] + + def resources(self, subject: str, env) -> list[str]: + """The Resources.Load paths whose objects are roots of `subject` (from the facts alone).""" + return [] + + def builtins(self, subject: str, env) -> list[str]: + """The built-in selectors ("", "MonoBehaviour:\n' - files[f"{sitefiles.STORY_PAGE_DIR}/story-list.js"] = 'import { x } from "../../src/story/define.js";\n' - if story_bundle: - files[sitefiles.STORY_BUNDLES[0]] = "/* story */\n" - for rel, text in files.items(): - write(root / rel, text) - return root - - -def test_write_player_story_page(tmp_path): - site = tmp_path / "site" - r = sitefiles.write_player(site, fake_player(tmp_path / "p")) - assert r["storyPageFiles"] == 2 - assert (site / "story/story-list.js").read_text( - encoding="utf-8" - ) == 'import { x } from "./ournotes-player.story.element.min.js";\n' - assert (site / "story/ournotes-player.story.element.min.js").is_file() - site2 = tmp_path / "site2" - r = sitefiles.write_player(site2, fake_player(tmp_path / "q", story_bundle=False)) - assert "storyPageFiles" not in r and not (site2 / "story").exists() - with pytest.raises(ConfigError, match="story page"): - sitefiles.write_player(site2, fake_player(tmp_path / "q", story_bundle=False), stories=True) - - -# ---------------------------------------------------------------- the models of the stories -MODEL_A, MODEL_B = "Character/Live2D/g/a/model/a", "Character/Live2D/g/b/model/b" - - -def put_model(site, key, motion_sync=False): - """A model manifest as webmodel.ingest writes it (made-up model.json and moc3).""" - mid = webmodel.model_id(key) - store = sitefiles.Store(site) - doc = {"format": webmodel.MODEL_FORMAT, "key": key, "motionSync": motion_sync} - man = { - "format": sitefiles.MANIFEST_FORMAT, - "id": mid, - "key": key, - "model": {}, - "files": { - "model.json": store.put("model.json", json.dumps(doc).encode()), - f"{mid}.moc3": store.put(f"{mid}.moc3", b"MOC3-" + mid.encode()), - }, - } - write(site / "models" / f"{mid}.json", sitefiles._dump(man)) - return {"id": mid, "ok": True, "files": 2, "bytes": sum(e["size"] for e in man["files"].values())} - - -@pytest.fixture -def story_build(tmp_path, monkeypatch): - """storysite.build over synthetic master data (MasterAdv 5, 6, 7, 8) and episodes: episode_models gives the - models each episode loads, build_dirs writes the story directory with the models it takes from the site - (webmodel.SiteModels, as story.build does), webmodel's model task writes a model manifest. -> run(ids, **kw), - with the calls in run.episodes (pre-pass) and run.models (model builds).""" - uses = {5: [MODEL_A], 6: [MODEL_B, MODEL_A], 7: [], 8: [MODEL_A]} - md = tmp_path / "master" - write(md / "MasterAdv.json", {"_allData": [{"_id": i} for i in uses]}) - font = tmp_path / "f.otf" - font.write_bytes(b"font") - cfg = Config( - { - "catalog": {"region": "zz", "language": "en"}, - "servers": {"zz": {"cdn": "https://cdn.invalid"}}, - "paths": {"master": str(md), "fonts": {"ja": str(font), "en": str(font)}}, - }, - environ={}, - ) - site = tmp_path / "site" - episodes, models = [], [] - - def episode_models(cat, master_dir, adv_id): - episodes.append(adv_id) - return sorted(uses[adv_id]) - - def build_dirs(cat, master_dir, player, adv_id, work, job): - src = webmodel.SiteModels(job["site"]) - return story_dirs(work, adv_id, mode=0, models={k: src.ensure(k)["id"] for k in uses[adv_id]}) - - def model_task(mid, key, job, data=None): - models.append(mid) - return put_model(Path(job["site"]), key) - - monkeypatch.setattr(tmpfont, "require_extra", lambda what=None: None) - monkeypatch.setattr(storysite, "open_data", lambda cfg, region=None: (None, md, None)) - monkeypatch.setattr(storysite, "episode_models", episode_models) - monkeypatch.setattr(storysite, "build_dirs", build_dirs) - monkeypatch.setattr(storysite.storyhost, "build", lambda *a, **kw: None) - monkeypatch.setattr(storysite, "font_file", lambda job, lang: None) - monkeypatch.setattr(webmodel, "model_task", model_task) - monkeypatch.setattr(webmodel, "_open", lambda cfg, region=None: (None, None)) - player = fake_player(tmp_path / "p") - - def run(ids, **kw): - return storysite.build(site, ids, cfg, player, audio=False, workers=1, story_languages=["ja", "en"], **kw) - - run.site, run.episodes, run.models = site, episodes, models - return run - - -def manifest(site, adv_id): - return json.loads((site / "stories" / f"{adv_id}.json").read_text(encoding="utf-8")) - - -def test_story_build_builds_the_models_of_its_stories_first(story_build): - site = story_build.site - r = story_build([5, 6, 7]) - assert r["storiesFailed"] == [] and [s["id"] for s in r["storiesBuilt"]] == ["5", "6", "7"] - assert sorted(story_build.episodes) == [5, 6, 7] - assert story_build.models == ["a", "b"] # each model once, whatever the stories using it - assert [m["id"] for m in r["storyModels"]["modelsBuilt"]] == ["a", "b"] and r["models"] == 2 - index = json.loads((site / "models.json").read_text(encoding="utf-8")) - assert [m["id"] for m in index["models"]] == ["a", "b"] - assert manifest(site, 5)["models"] == {"a": "models/a.json"} - assert manifest(site, 6)["models"] == {"a": "models/a.json", "b": "models/b.json"} - assert manifest(site, 7)["models"] == {} and manifest(site, 7)["root"] == "../" - assert json.loads(sitefiles.entry_text(site, manifest(site, 6)["files"]["story.json"]))["models"] == { - MODEL_B: "b", - MODEL_A: "a", - } - stories = {e["id"]: e for e in json.loads((site / "stories.json").read_text(encoding="utf-8"))["stories"]} - size = { - m: sum(e["size"] for e in json.loads((site / f"models/{m}.json").read_text(encoding="utf-8"))["files"].values()) - for m in ("a", "b") - } - assert (stories["5"]["size"]["models"], stories["6"]["size"]["models"], stories["7"]["size"]["models"]) == ( - size["a"], - size["a"] + size["b"], - 0, - ) - for mf in (site / "models").glob("*.json"): # write_index kept the models' assets - for e in json.loads(mf.read_text(encoding="utf-8"))["files"].values(): - assert all((site / a).is_file() for a in sitefiles.entry_assets(e)) - - -def test_story_build_skips_existing_models_unless_forced(story_build): - story_build([5]) - assert story_build.models == ["a"] - # stories whose manifest exists: no pre-pass, no model build - r = story_build([5]) - assert r["storiesSkipped"] == ["5"] and r["storyModels"] is None and story_build.episodes == [5] - # a new story whose model exists: the model manifest is skipped - r = story_build([8]) - assert story_build.models == ["a"] and r["storyModels"]["modelsSkipped"] == ["a"] - assert manifest(story_build.site, 8)["models"] == {"a": "models/a.json"} - # force: the stories and their models built again, each model once - r = story_build([5, 8, 6], force=True) - assert story_build.models == ["a", "a", "b"] and r["storyModels"]["modelsSkipped"] == [] - assert sorted(s["id"] for s in r["storiesBuilt"]) == ["5", "6", "8"] - - -def test_story_build_rebuilds_outdated_models(story_build): - """A model manifest whose model.json is older than MODEL_FORMAT (no motionSync) is built again by the stories' - model build without force; a current one is skipped.""" - site = story_build.site - put_model(site, MODEL_B) # current - put_model(site, MODEL_A) - man = json.loads((site / "models/a.json").read_text(encoding="utf-8")) - man["files"]["model.json"] = sitefiles.Store(site).put( - "model.json", json.dumps({"format": 1, "key": MODEL_A}).encode() - ) - write(site / "models/a.json", sitefiles._dump(man)) - r = story_build([6]) - assert story_build.models == ["a"] - assert r["storyModels"]["modelsRebuilt"] == [{"id": "a", "reason": "outdated"}] - assert r["storyModels"]["modelsSkipped"] == ["b"] and [m["id"] for m in r["storyModels"]["modelsBuilt"]] == ["a"] - assert r["storiesFailed"] == [] and webmodel.outdated(site, "a") is None - assert manifest(site, 6)["models"] == {"a": "models/a.json", "b": "models/b.json"} - - -def test_a_story_without_its_model_fails(story_build, monkeypatch): - monkeypatch.setattr( - webmodel, - "model_task", - lambda mid, key, job, data=None: {"id": mid, "ok": False, "stage": "export", "error": "x"}, - ) - r = story_build([5, 7]) - assert r["storyModels"]["modelsFailed"] == [{"id": "a", "stage": "export", "error": "x"}] - assert [(f["id"], f["stage"]) for f in r["storiesFailed"]] == [("5", "export")] - assert "no model manifest models/a.json" in r["storiesFailed"][0]["error"] - assert [s["id"] for s in r["storiesBuilt"]] == ["7"] - - -def test_cli_web_stories(tmp_path, capsys, monkeypatch): - from nnnotes import cli - from nnnotes.cli import common - - calls = [] - - def fake_build(out, ids, cfg, player, fmt, **kw): - calls.append((ids, fmt, kw)) - return {"site": str(out), "storiesBuilt": [], "storiesFailed": [], "storiesSkipped": []} - - monkeypatch.setattr(storysite, "build", fake_build) - monkeypatch.setattr(storysite, "unknown_stories", lambda cfg, ids, regions=None: []) - monkeypatch.setattr(tmpfont, "require_extra", lambda what=None: None) # the fonts extra is optional - cli.main( - [ - "--apk", - str(tmp_path / "base.apk"), - "--json", - "site", - "build", - "-o", - str(tmp_path / "s"), - "--player", - str(fake_player(tmp_path / "p")), - "--story", - "10462", - "--story", - "7", - "--story-languages", - "en,ja", - "--font", - "en=a.otf", - "--font", - "ja=b.otf", - "--font", - "emoji=e.ttf", - "--no-audio", - "--workers", - "2", - ] - ) - ((ids, fmt, kw),) = calls - assert ids == [10462, 7] and fmt == "aac" and kw["audio"] is False and kw["workers"] == 2 - assert kw["story_languages"] == ["en", "ja"] - assert kw["fonts_flags"] == {"en": "a.otf", "ja": "b.otf", "emoji": "e.ttf"} - assert common.EMOJI_FONT == storysite.EMOJI_FONT - assert kw["fonts"] == "open" and kw["encoding"] == "gzip" - assert json.loads(capsys.readouterr().out)["storiesFailed"] == [] - - -def test_cli_web_stories_need_the_fonts_extra(tmp_path, capsys, monkeypatch): - from nnnotes import cli - - monkeypatch.setattr(tmpfont.importlib.util, "find_spec", lambda name: None) - with pytest.raises(SystemExit) as e: - cli.main( - [ - "--apk", - str(tmp_path / "base.apk"), - "--json", - "site", - "build", - "-o", - str(tmp_path / "s"), - "--player", - str(fake_player(tmp_path / "p")), - "--story", - "1", - "--no-audio", - ] - ) - err = capsys.readouterr().err - assert e.value.code == 2 and "--story / --all-stories" in err and "nnnotes[fonts]" in err - - -@pytest.mark.parametrize( - "argv, message", - [ - (["--story", "1", "--player-only"], "leave out --story"), - (["--font", "fr=x.otf", "--all-stories"], "expected ="), - (["--story-languages", "en,xx", "--all-stories"], "expected languages"), - (["--story", "1", "--all-stories"], "not allowed with argument"), - ], -) -def test_cli_web_story_usage(tmp_path, capsys, argv, message): - from nnnotes import cli - - with pytest.raises(SystemExit) as e: - cli.main( - ["--json", "site", "build", "-o", str(tmp_path / "s"), "--player", str(fake_player(tmp_path / "p")), *argv] - ) - assert e.value.code == 2 and message in capsys.readouterr().err diff --git a/tests/site/test_web.py b/tests/site/test_web.py deleted file mode 100644 index ac12eb9..0000000 --- a/tests/site/test_web.py +++ /dev/null @@ -1,368 +0,0 @@ -import gzip -import hashlib -import json - -import pytest - -from nnnotes.site import compress, sitefiles, siteindex, web -from nnnotes.source.config import ConfigError - -DOC = sitefiles._minify({"rows": [{"id": i, "name": f"row {i}", "tags": ["a", "b"]} for i in range(200)]}).encode() - - -def test_text_asset_minifies_and_drops_doc_fields(): - doc = {"spec": "notes for readers", "music": {"soundId": 1}, "voice": {"rule": "r", "x": 1}, "v": 1e999} - out = sitefiles.text_asset("audio/live-audio.json", json.dumps(doc, indent=2).replace("Infinity", "1e999")) - assert out == b'{"music":{"soundId":1},"voice":{"x":1},"v":1e999}' - glsl = "#ifdef VERTEX\nvoid main() {}\n#endif\n" - assert sitefiles.text_asset("shaders/a.glsl", glsl) == glsl.encode("utf-8") - - -def test_split_json_rejoins(): - assert sitefiles.split_json(b'{"a":1,"b":2}') is None - big = sitefiles._minify({"a": "x" * (sitefiles.SPLIT_MIN_BYTES // 2), "b": ["y"] * 200000, "c": {"d": 1}}).encode() - parts = sitefiles.split_json(big) - assert [k for k, _ in parts] == ["a", "b", "c"] - assert sitefiles.join_parts(parts) == big - odd = sitefiles._minify({"a b": "x" * sitefiles.SPLIT_MIN_BYTES, "c": 1}).encode() - assert sitefiles.split_json(odd) is None - - -def test_store_is_content_addressed(tmp_path): - store = sitefiles.Store(tmp_path) - e = store.put("x/data.json", b"{}") - assert e == {"asset": f"assets/{hashlib.sha256(b'{}').hexdigest()}.json", "size": 2} - assert store.put("y/other.json", b"{}") == e - assert (store.written, store.reused) == (1, 1) - assert store.put("noext", b"z")["asset"].endswith(".bin") - - -def sha(data): - return hashlib.sha256(data).hexdigest() - - -def test_store_encodes_compressible_files_when_smaller(tmp_path): - store = sitefiles.Store(tmp_path) - e = store.put("x/doc.json", DOC) - assert e == {"asset": f"assets/{sha(DOC)}.json.gz", "size": len(DOC), "stored": len(compress.gzip_bytes(DOC))} - assert e["stored"] < e["size"] - assert (tmp_path / e["asset"]).read_bytes() == compress.gzip_bytes(DOC) - assert sitefiles.read_asset(tmp_path, e["asset"]) == DOC - shader = b"void main() { gl_FragColor = vec4(1.0); }\n" * 20 - assert store.put("s/a.glsl", shader)["asset"] == f"assets/{sha(shader)}.glsl.gz" - assert store.put("x/tiny.json", b"{}") == {"asset": f"assets/{sha(b'{}')}.json", "size": 2} # gzip not smaller - assert store.put("t/a.png", DOC) == {"asset": f"assets/{sha(DOC)}.png", "size": len(DOC)} # not compressible - with pytest.raises(ValueError, match="asset encoding"): - sitefiles.Store(tmp_path, "zstd") - - -def test_store_brotli(tmp_path): - pytest.importorskip("brotli") - e = sitefiles.site_store(tmp_path, "br").put("m/model.moc3", DOC) - assert e == {"asset": f"assets/{sha(DOC)}.moc3.br", "size": len(DOC), "stored": len(compress.brotli_bytes(DOC))} - assert sitefiles.read_asset(tmp_path, e["asset"]) == DOC - - -def test_store_none_keeps_todays_names_and_bytes(tmp_path): - store = sitefiles.site_store(tmp_path, "none") - assert store.put("x/doc.json", DOC) == {"asset": f"assets/{sha(DOC)}.json", "size": len(DOC)} - assert [p.name for p in (tmp_path / "assets").iterdir()] == [f"{sha(DOC)}.json"] - assert (tmp_path / "assets" / f"{sha(DOC)}.json").read_bytes() == DOC - - -def test_store_reuses_encoded_names(tmp_path): - store = sitefiles.Store(tmp_path) - e = store.put("a.json", DOC) - assert store.put("b.json", DOC) == e and (store.written, store.reused) == (1, 1) - again = sitefiles.Store(tmp_path) - assert again.put("c.json", DOC) == e and (again.written, again.reused) == (0, 1) - # a file of that name that holds the bytes is kept whatever encoder wrote it; its length is the entry's `stored` - other = gzip.compress(DOC, 1, mtime=0) - (tmp_path / e["asset"]).write_bytes(other) - kept = sitefiles.Store(tmp_path).put("d.json", DOC) - assert kept == {**e, "stored": len(other)} and (tmp_path / e["asset"]).read_bytes() == other - (tmp_path / e["asset"]).write_bytes(other[:-5]) # damaged: written again - fresh = sitefiles.Store(tmp_path) - assert fresh.put("e.json", DOC) == e and fresh.written == 1 - assert (tmp_path / e["asset"]).read_bytes() == compress.gzip_bytes(DOC) - - -def test_split_parts_carry_stored(tmp_path): - big = sitefiles._minify({"a": "x" * (sitefiles.SPLIT_MIN_BYTES // 2), "b": ["y"] * 200000, "c": {"d": 1}}).encode() - e = sitefiles.Store(tmp_path).put_file("scene.json", big) - assert e["size"] == len(big) and [p[0] for p in e["parts"]] == ["a", "b", "c"] - a, b, c = e["parts"] - small = b'{"d":1}' - assert len(a) == len(b) == 4 and a[1].endswith(".json.gz") and a[3] < a[2] - assert c == ["c", f"assets/{sha(small)}.json", len(small)] # a part gzip does not make smaller - assert sitefiles.entry_text(tmp_path, e) == big - raw = sitefiles.Store(tmp_path / "raw", "none").put_file("scene.json", big) - assert all(len(p) == 3 for p in raw["parts"]) and sitefiles.entry_text(tmp_path / "raw", raw) == big - - -def test_stored_text_memo_follows_the_encoding(tmp_path): - doc = json.dumps({"k": list(range(300))}) - a = web.stored_text(sitefiles.Store(tmp_path), "x/a.json", doc, memo="m") - b = web.stored_text(sitefiles.Store(tmp_path, "none"), "x/a.json", doc, memo="m") - assert a["asset"].endswith(".json.gz") and b == {"asset": a["asset"][:-3], "size": a["size"]} - - -def test_ingest_task_stores_in_the_jobs_encoding(tmp_path, monkeypatch): - seen = [] - monkeypatch.setattr(web, "ingest", lambda store, *a, **k: seen.append(store.encoding) or {"id": "1_easy"}) - cfg = {"site": str(tmp_path), "audioFormat": "aac", "audio": True, "language": "en"} - web.ingest_task(1, [("easy", str(tmp_path), {}, [])], str(tmp_path), {**cfg, "encoding": "none"}) - web.ingest_task(1, [("easy", str(tmp_path), {}, [])], str(tmp_path), cfg) - assert seen == ["none", "gzip"] - - -def manifest(site_dir, music_id, difficulty, store, files): - entries = {p: store.put(p, data) for p, data in files.items()} - doc = { - "musicId": music_id, - "difficulty": difficulty, - "audio": True, - "audioFormat": "aac", - "flows": ["direct"], - "files": entries, - "chart": {"title": f"t{music_id}", "level": 1}, - } - (site_dir / "charts" / f"{music_id}_{difficulty}.json").write_text(json.dumps(doc), encoding="utf-8") - - -def test_write_index_orders_charts_and_prunes_assets(tmp_path): - (tmp_path / "charts").mkdir() - store = sitefiles.Store(tmp_path) - manifest(tmp_path, 2, "easy", store, {"live.json": b"{}"}) - manifest(tmp_path, 1, "expert", store, {"live.json": b"{}", "score/a.json": b"[1]"}) - manifest(tmp_path, 1, "easy", store, {"live.json": b"{}"}) - stale = store.put("old.json", b"[0]") - r = siteindex.write_index(tmp_path) - assert (r["charts"], r["assets"], r["removedAssets"], r["assetBytes"], r["bytes"]) == (3, 2, 1, 5, 5) - assert not (tmp_path / stale["asset"]).exists() - index = json.loads((tmp_path / "charts.json").read_text(encoding="utf-8")) - assert index["format"] == sitefiles.SITE_FORMAT - assert [c["id"] for c in index["charts"]] == ["1_easy", "1_expert", "2_easy"] - assert index["charts"][1]["bytes"] == 5 and index["charts"][1]["title"] == "t1" - - -def encoded_site(site): - """Two charts sharing an encoded file, a raw file each, and a stale encoded asset.""" - (site / "charts").mkdir(parents=True) - store = sitefiles.Store(site) - manifest(site, 1, "easy", store, {"live.json": b"{}", "scene/a.json": DOC}) - manifest(site, 1, "hard", store, {"live.json": b"[1]", "scene/a.json": DOC}) - return store.put("old.json", DOC + b" ") - - -def test_write_index_prunes_encoded_names(tmp_path): - stale = encoded_site(tmp_path) - assert stale["asset"].endswith(".json.gz") - r = siteindex.write_index(tmp_path) - assert (r["charts"], r["assets"], r["removedAssets"]) == (2, 3, 1) - assert not (tmp_path / stale["asset"]).exists() - names = sorted(p.name for p in (tmp_path / "assets").iterdir()) - assert names == sorted([f"{sha(DOC)}.json.gz", f"{sha(b'{}')}.json", f"{sha(b'[1]')}.json"]) - assert (r["assetBytes"], r["bytes"]) == (len(compress.gzip_bytes(DOC)) + 2 + 3, len(DOC) + 2 + 3) - index = json.loads((tmp_path / "charts.json").read_text(encoding="utf-8")) - assert [c["bytes"] for c in index["charts"]] == [len(DOC) + 2, len(DOC) + 3] # decoded sizes - - -def fake_player(root): - for rel, text in { - sitefiles.READ_SET_SCRIPT: "// read set\n", - sitefiles.PLAYER_BUNDLES[0]: "/* bundle */\n", - f"{sitefiles.PLAYER_PAGE_DIR}/index.html": ( - '\n' - ), - f"{sitefiles.PLAYER_PAGE_DIR}/chart-list.js": 'import "../../src/element.js";\n', - }.items(): - p = root / rel - p.parent.mkdir(parents=True, exist_ok=True) - p.write_bytes(text.encode("utf-8")) - return root - - -def test_check_player(tmp_path): - with pytest.raises(ConfigError, match="NNNOTES_PATHS_PLAYER"): - sitefiles.check_player(None) - with pytest.raises(ConfigError, match="not a built ournotes-player"): - sitefiles.check_player(tmp_path) - assert sitefiles.check_player(fake_player(tmp_path / "p")) == (tmp_path / "p").resolve() - - -def test_write_player(tmp_path): - player = fake_player(tmp_path / "p") - out = tmp_path / "site" - r = sitefiles.write_player(out, player) - version = hashlib.sha256(b"/* bundle */\n").hexdigest()[:16] - assert r["playerVersion"] == version and r["pageFiles"] == 2 - assert (out / "chart-list.js").read_text(encoding="utf-8") == 'import "./ournotes-player.element.min.js";\n' - assert version in (out / "index.html").read_text(encoding="utf-8") - assert (out / "ournotes-player.element.min.js").is_file() - (player / sitefiles.PLAYER_PAGE_DIR / "extra.js").write_bytes(b'import "../../src/other.js";\n') - with pytest.raises(RuntimeError, match="beyond PAGE_IMPORTS"): - sitefiles.write_player(out, player) - - -def test_write_player_copies_the_complete_songs_module_tree(tmp_path): - player = fake_player(tmp_path / "p") - files = { - "index.html": b'', - "songs.js": b'import "./catalog.js"; import "./replay-panel.js";', - "catalog.js": b"export const catalog = {};", - "text.js": b"export const text = {};", - "replay-panel.js": b'import "./replay-preset.js";', - "replay-worker.js": b"// worker", - "replay-preset.js": b"// preset", - "nested/future-dependency.json": b"{}", - } - for name, data in files.items(): - path = player / sitefiles.SONGS_PAGE_DIR / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(data) - site = tmp_path / "site" - result = sitefiles.write_player(site, player) - assert result["songsPageFiles"] == len(files) - assert { - path.relative_to(site / "songs").as_posix(): path.read_bytes() - for path in (site / "songs").rglob("*") - if path.is_file() - } == files - - -def test_player_only_command(tmp_path, capsys): - from nnnotes import cli - - out = tmp_path / "site" - (out / "assets").mkdir(parents=True) - (out / "assets" / "unreferenced.json").write_bytes(b"{}") - cli.main(["--json", "site", "build", "-o", str(out), "--player", str(fake_player(tmp_path / "p")), "--player-only"]) - r = json.loads(capsys.readouterr().out) - assert (r["charts"], r["removedAssets"], r["pageFiles"]) == (0, 1, 2) - assert json.loads((out / "charts.json").read_text(encoding="utf-8")) == { - "charts": [], - "format": sitefiles.SITE_FORMAT, - } - - -def test_player_only_and_reingest_over_an_encoded_site(tmp_path, capsys): - from nnnotes import cli - - site, player = tmp_path / "site", str(fake_player(tmp_path / "p")) - encoded_site(site) - before = (site / "charts" / "1_easy.json").read_bytes() - cli.main(["--json", "site", "build", "-o", str(site), "--player", player, "--player-only"]) - r = json.loads(capsys.readouterr().out) - assert (r["charts"], r["assets"], r["removedAssets"]) == (2, 3, 1) - cli.main(["--json", "site", "build", "-o", str(site), "--player", player, "--reingest-json"]) - r = json.loads(capsys.readouterr().out) - assert (r["changedEntries"], r["assets"], r["removedAssets"]) == (0, 3, 0) - assert json.loads((site / "charts" / "1_easy.json").read_bytes()) == json.loads(before) - cli.main(["--json", "site", "build", "-o", str(site), "--player", player, "--reingest-json", "--compress", "none"]) - r = json.loads(capsys.readouterr().out) - assert (r["changedEntries"], r["assets"], r["removedAssets"]) == (2, 3, 1) - man = json.loads((site / "charts" / "1_easy.json").read_text(encoding="utf-8")) - assert man["files"]["scene/a.json"] == {"asset": f"assets/{sha(DOC)}.json", "size": len(DOC)} - assert r["assetBytes"] == r["bytes"] == len(DOC) + 2 + 3 - - -def test_player_only_on_a_new_directory(tmp_path, capsys): - from nnnotes import cli - - out = tmp_path / "new-site" - cli.main(["--json", "site", "build", "-o", str(out), "--player", str(fake_player(tmp_path / "p")), "--player-only"]) - r = json.loads(capsys.readouterr().out) - assert (r["charts"], r["assets"], r["removedAssets"]) == (0, 0, 0) - assert (out / "index.html").is_file() and (out / "charts.json").is_file() - - -@pytest.mark.parametrize("failed, code", [([], 0), ([{"id": "1_easy", "stage": "live", "error": "x"}], 1)]) -def test_web_exit_status_follows_failed_charts(tmp_path, capsys, monkeypatch, failed, code): - from nnnotes import cli - - calls = [] - - def fake_build(out, pairs, cfg, player, fmt, **kw): - calls.append((pairs, fmt, kw)) - return {"site": str(out), "built": [], "failed": failed, "skipped": []} - - monkeypatch.setattr(web, "build", fake_build) - monkeypatch.setattr(web, "unknown_pairs", lambda cfg, pairs, regions=None: []) # no master data here - exit_code = 0 - try: - cli.main( - [ - "--json", - "site", - "build", - "-o", - str(tmp_path / "s"), - "--player", - str(fake_player(tmp_path / "p")), - "--pair", - "7:hard", - "--pair", - "7_easy", - "--format", - "opus", - "--no-audio", - "--workers", - "1", - ] - ) - except SystemExit as e: - exit_code = e.code - assert exit_code == code - assert json.loads(capsys.readouterr().out)["failed"] == failed - ((pairs, fmt, kw),) = calls - assert pairs == [(7, "hard"), (7, "easy")] and fmt == "opus" - assert kw["audio"] is False and kw["workers"] == 1 and kw["force"] is False and kw["encoding"] == "gzip" - - -def test_web_compress_flag_reaches_the_build(tmp_path, capsys, monkeypatch): - from nnnotes import cli - - calls = [] - - def fake_build(out, pairs, cfg, player, fmt, **kw): - calls.append(kw["encoding"]) - return {"site": str(out), "built": [], "failed": [], "skipped": []} - - monkeypatch.setattr(web, "build", fake_build) - monkeypatch.setattr(web, "unknown_pairs", lambda cfg, pairs, regions=None: []) - player = str(fake_player(tmp_path / "p")) - for flag in ("br", "none"): - cli.main( - [ - "--json", - "site", - "build", - "-o", - str(tmp_path / "s"), - "--player", - player, - "--pair", - "7:hard", - "--compress", - flag, - ] - ) - assert calls == ["br", "none"] - with pytest.raises(SystemExit): - cli.main( - [ - "--json", - "site", - "build", - "-o", - str(tmp_path / "s"), - "--player", - player, - "--pair", - "7:hard", - "--compress", - "zstd", - ] - ) - capsys.readouterr() diff --git a/tests/site/test_webmodel.py b/tests/site/test_webmodel.py deleted file mode 100644 index 814231f..0000000 --- a/tests/site/test_webmodel.py +++ /dev/null @@ -1,940 +0,0 @@ -import ast -import hashlib -import inspect -import json -from types import SimpleNamespace - -import pytest - -from nnnotes import cli -from nnnotes.core import jsonio -from nnnotes.site import sitefiles, siteindex, web, webmodel -from nnnotes.source.config import Config, ConfigError -from nnnotes.source.session import Session -from nnnotes.unity import live2d -from nnnotes.unity import player as nnnotes_player - -KEY_A = "Character/Live2D/001_adv/adv_model_a/model/adv_model_a" -KEY_B = "Character/Live2D/sub_x/adv_model_b/model/adv_model_b" - - -def test_model_id_is_the_model_name(): - assert webmodel.model_id(KEY_A) == "adv_model_a" - for bad in ( - "Character/Live2D/001_adv/a/model/b", - "Character/Live2D/a/model/a", - "Other/001/a/model/a", - "Character/Live2D/001_adv/a/model/a/extra", - ): - with pytest.raises(ValueError, match="not a Live2D model key"): - webmodel.model_id(bad) - with pytest.raises(ValueError, match="not URL-safe"): - webmodel.model_id("Character/Live2D/g/a b/model/a b") - - -def test_model_keys_and_select(): - keys = [KEY_B, "Character/Live2D/001_adv/adv_model_a/motion/x", KEY_A, "Live/Score/1"] - models = webmodel.model_keys(keys) - assert models == {"adv_model_a": KEY_A, "adv_model_b": KEY_B} - assert list(models) == ["adv_model_a", "adv_model_b"] - assert webmodel.select(None, models) == models - assert webmodel.select([KEY_B, "adv_model_a"], models) == models - assert webmodel.select(["adv_model_b"], models) == {"adv_model_b": KEY_B} - with pytest.raises(ValueError, match="nope"): - webmodel.select(["adv_model_a", "nope"], models) - with pytest.raises(ValueError, match="model id adv_model_a"): - webmodel.model_keys([KEY_A, "Character/Live2D/002_adv/adv_model_a/model/adv_model_a"]) - - -def test_shader_names(): - doc = { - "nodes": [ - { - "materials": [ - {"material": "Lit", "shader": {"shader": "S/Lit"}}, - {"material": "Mask", "shader": {"shader": "S/Mask"}}, - ] - } - ], - "other": {"shader": "not a reference", "x": 1}, - } - assert webmodel.shader_names(doc) == {"S/Lit", "S/Mask"} - - -def drawable(tex, keywords, shader="S/Lit"): - return { - "path": "m/Drawables/d", - "components": [ - { - "type": "MeshRenderer", - "m_Materials": [{"material": "Lit", "shader": {"shader": shader}, "keywords": keywords}], - }, - {"type": "MonoBehaviour", "class": "CubismRenderer", "_mainTexture": {"texture": tex, "name": "page"}}, - ], - } - - -def variant(shader, n, keywords, platform="gles3", kind="GLES3", ext="glsl"): - return { - "file": f"{shader}/{platform}/s0p0_vertex_{n}.{ext}", - "platform": platform, - "subShader": 0, - "pass": 0, - "stage": "vertex", - "type": kind, - "keywords": keywords, - } - - -ALV = "_ADDITIONAL_LIGHTS_VERTEX" -SHADER_INDEX = [ - { - "name": "S/Lit", - "source": "x", - "parsed": "S_Lit.json", - "variants": [ - variant("S_Lit", 0, []), - variant("S_Lit", 1, ["CUBISM_MASK_ON"]), - variant("S_Lit", 2, ["_ADDITIONAL_LIGHTS"]), - variant("S_Lit", 3, [], kind="GLES31"), - variant("S_Lit", 4, [], platform="vulkan", kind="SPIRV", ext="vkprog"), - variant("S_Lit", 5, [ALV]), - variant("S_Lit", 6, ["CUBISM_MASK_ON", ALV]), - ], - }, - {"name": "S/Mask", "source": "y", "parsed": "S_Mask.json", "variants": [variant("S_Mask", 0, [])]}, -] -MASKS = { - "cubismMask": {"material": "Mask", "shader": {"shader": "S/Mask"}, "keywords": [], "floats": {"_Cull": 0.0}}, - "cubismMaskCulling": { - "material": "MaskCulling", - "shader": {"shader": "S/Mask"}, - "keywords": [], - "floats": {"_Cull": 1.0}, - }, -} - - -def fake_model(root, name="m", tex=b"\x89PNG texture a", moc=b"MOC3 bytes", masked=True, key=KEY_A, motion_sync=False): - """A model directory in the export layout (made-up contents) and its export summary. Two atlas pages, the - drawables use page 0; drawables masked or not; the root node with the MotionSync components or not.""" - kws = [[], ["CUBISM_MASK_ON"]] if masked else [[], []] - root_node = { - "path": name, - "components": [ - {"type": "MonoBehaviour", "class": c} for c in (webmodel.MOTION_SYNC_COMPONENTS if motion_sync else ()) - ], - } - prefab = { - "key": name, - "nodes": [root_node, *(drawable("textures/p0-00.png", k) for k in kws)], - "canvas": {"width": 1.0}, - } - doc = { - "format": webmodel.MODEL_FORMAT, - "name": name, - "key": key, - "moc3": f"{name}.moc3", - "prefab": f"{name}.prefab.json", - "textures": webmodel.drawable_textures(prefab), - "canvas": prefab["canvas"], - "shaders": "shaders/shaders.json", - "resources": MASKS, - "motionSync": webmodel.has_motion_sync(prefab), - } - files = { - f"{name}.moc3": moc, - f"{name}.prefab.json": json.dumps(prefab, indent=1).encode(), - "textures/p0-00.png": tex, - "textures/p1-11.png": b"unused page", - "shaders/shaders.json": json.dumps(SHADER_INDEX).encode(), - webmodel.MODEL_INDEX: json.dumps(doc).encode(), - "shaders/S_Lit.json": b'{"name": "S/Lit"}', - "shaders/S_Mask.json": b'{"name": "S/Mask"}', - } - for rec in SHADER_INDEX: - for v in rec["variants"]: - files[f"shaders/{v['file']}"] = f"#ifdef VERTEX\n// {v['file']}\n#endif\n".encode() - for rel, data in files.items(): - p = root / rel - p.parent.mkdir(parents=True, exist_ok=True) - p.write_bytes(data) - summary = { - "name": name, - "textures": doc["textures"], - "nodes": len(prefab["nodes"]), - "canvas": prefab["canvas"], - "files": webmodel.read_files(doc, prefab, SHADER_INDEX), - } - return root, summary - - -MASKED_FILES = [ - "m.moc3", - "m.prefab.json", - "model.json", - "shaders/S_Lit.json", - "shaders/S_Lit/gles3/s0p0_vertex_0.glsl", - "shaders/S_Lit/gles3/s0p0_vertex_1.glsl", - "shaders/S_Lit/gles3/s0p0_vertex_5.glsl", - "shaders/S_Lit/gles3/s0p0_vertex_6.glsl", - "shaders/S_Mask.json", - "shaders/S_Mask/gles3/s0p0_vertex_0.glsl", - "shaders/shaders.json", - "textures/p0-00.png", -] - - -def test_read_rule(tmp_path): - _, summary = fake_model(tmp_path / "a", masked=True) - assert summary["files"] == MASKED_FILES - _, summary = fake_model(tmp_path / "b", masked=False) - assert summary["files"] == [ - "m.moc3", - "m.prefab.json", - "model.json", - "shaders/S_Lit.json", - "shaders/S_Lit/gles3/s0p0_vertex_0.glsl", - "shaders/S_Lit/gles3/s0p0_vertex_5.glsl", - "shaders/shaders.json", - "textures/p0-00.png", - ] - # keywords the shader's programs do not use are ignored - lit = {"shader": {"shader": "S/Lit"}, "keywords": ["CUBISM_INVERT_ON"]} - assert webmodel.shader_files(SHADER_INDEX, [lit]) == ["S_Lit.json", "S_Lit/gles3/s0p0_vertex_0.glsl"] - with pytest.raises(RuntimeError, match="0 GLES3 programs"): - webmodel.shader_files( - SHADER_INDEX, [{"shader": {"shader": "S/Lit"}, "keywords": ["CUBISM_MASK_ON", "_ADDITIONAL_LIGHTS"]}] - ) - with pytest.raises(RuntimeError, match="not in the shader index"): - webmodel.shader_files(SHADER_INDEX, [{"shader": {"shader": "S/Other"}, "keywords": []}]) - - -def test_story_keywords_select_the_story_programs_too(): - lit = {"shader": {"shader": "S/Lit"}, "keywords": ["CUBISM_MASK_ON"]} - mask = MASKS["cubismMask"] - assert webmodel.STORY_KEYWORDS == (ALV,) - assert webmodel.shader_files(SHADER_INDEX, [lit], webmodel.STORY_KEYWORDS) == [ - "S_Lit.json", - "S_Lit/gles3/s0p0_vertex_1.glsl", - "S_Lit/gles3/s0p0_vertex_6.glsl", - ] - # a shader whose programs do not use the keyword: its one program - assert webmodel.shader_files(SHADER_INDEX, [mask], webmodel.STORY_KEYWORDS) == [ - "S_Mask.json", - "S_Mask/gles3/s0p0_vertex_0.glsl", - ] - # the keyword without a program for a material's set - index = [{**SHADER_INDEX[0], "variants": [v for v in SHADER_INDEX[0]["variants"] if v["keywords"] != [ALV]]}] - with pytest.raises(RuntimeError, match=r"0 GLES3 programs with the keywords \['_ADDITIONAL_LIGHTS_VERTEX'\]"): - webmodel.shader_files(index, [{"shader": {"shader": "S/Lit"}, "keywords": []}], webmodel.STORY_KEYWORDS) - - -def test_ingest_writes_a_manifest_of_the_read_files(tmp_path): - site = tmp_path / "site" - (site / sitefiles.MODELS_DIR).mkdir(parents=True) - mdir, summary = fake_model(tmp_path / "m") - r = webmodel.ingest(sitefiles.Store(site), site, "adv_model_a", KEY_A, mdir, summary) - man = json.loads((site / "models" / "adv_model_a.json").read_text(encoding="utf-8")) - assert r["ok"] and r["files"] == len(man["files"]) == 12 and sorted(man["files"]) == summary["files"] - assert man["format"] == sitefiles.MANIFEST_FORMAT == 3 and man["id"] == "adv_model_a" and man["key"] == KEY_A - assert man["model"] == {"group": "001_adv", "canvas": {"width": 1.0}, "textures": 1, "nodes": 3} - index = json.loads(sitefiles.entry_text(site, man["files"]["shaders/shaders.json"])) - assert [(r["name"], [v["file"] for v in r["variants"]]) for r in index] == [ - ( - "S/Lit", - [ - "S_Lit/gles3/s0p0_vertex_0.glsl", - "S_Lit/gles3/s0p0_vertex_1.glsl", - "S_Lit/gles3/s0p0_vertex_5.glsl", - "S_Lit/gles3/s0p0_vertex_6.glsl", - ], - ), - ("S/Mask", ["S_Mask/gles3/s0p0_vertex_0.glsl"]), - ] - prefab = sitefiles.entry_text(site, man["files"]["m.prefab.json"]) - assert json.loads(prefab)["canvas"] == {"width": 1.0} and b" " not in prefab - assert sitefiles.entry_text(site, man["files"]["m.moc3"]) == b"MOC3 bytes" - doc = json.loads(sitefiles.entry_text(site, man["files"]["model.json"])) - assert doc["format"] == webmodel.MODEL_FORMAT == 2 and doc["motionSync"] is False - - -def test_write_index_keeps_model_assets_and_writes_models_json(tmp_path): - site = tmp_path / "site" - (site / "charts").mkdir(parents=True) - (site / sitefiles.MODELS_DIR).mkdir() - store = sitefiles.Store(site) - shared = store.put("livescene/shaders/x.glsl", b"#ifdef VERTEX\n#endif\n") - chart = {"musicId": 1, "difficulty": "easy", "audio": False, "files": {"x.glsl": shared}, "chart": {}} - (site / "charts" / "1_easy.json").write_text(json.dumps(chart), encoding="utf-8") - webmodel.ingest(store, site, "adv_model_b", KEY_B, *fake_model(tmp_path / "b", "b", tex=b"tex b", masked=False)) - webmodel.ingest(store, site, "adv_model_a", KEY_A, *fake_model(tmp_path / "a", "a")) - stale = store.put("old.json", b"[0]") - r = siteindex.write_index(site) - assert (r["charts"], r["models"], r["removedAssets"]) == (1, 2, 1) - assert not (site / stale["asset"]).exists() - for mf in (site / "models").glob("*.json"): - for e in json.loads(mf.read_text(encoding="utf-8"))["files"].values(): - assert all((site / a).is_file() for a in sitefiles.entry_assets(e)) - index = json.loads((site / "models.json").read_text(encoding="utf-8")) - assert index["format"] == sitefiles.SITE_FORMAT - assert [m["id"] for m in index["models"]] == ["adv_model_a", "adv_model_b"] - a = index["models"][0] - assert a["manifest"] == "models/adv_model_a.json" and a["key"] == KEY_A and a["group"] == "001_adv" - assert a["files"] == 12 and a["bytes"] > 0 and a["textures"] == 1 - # the two models share their shader files: stored once - shared_glsl = { - json.loads((site / "models" / f"{i}.json").read_text(encoding="utf-8"))["files"][ - "shaders/S_Lit/gles3/s0p0_vertex_0.glsl" - ]["asset"] - for i in ("adv_model_a", "adv_model_b") - } - assert len(shared_glsl) == 1 - # removing a model's manifest frees its own assets on the next index - (site / "models" / "adv_model_b.json").unlink() - r = siteindex.write_index(site) - assert r["models"] == 1 and r["removedAssets"] >= 1 - - -def test_no_models_directory_no_models_json(tmp_path): - (tmp_path / "models.json").write_bytes(b"{}") - r = siteindex.write_index(tmp_path) - assert (r["charts"], r["models"]) == (0, 0) - assert not (tmp_path / "models.json").exists() - (tmp_path / "models").mkdir() - siteindex.write_index(tmp_path) - assert json.loads((tmp_path / "models.json").read_text(encoding="utf-8")) == { - "format": sitefiles.SITE_FORMAT, - "models": [], - } - - -def test_reingest_json_covers_models(tmp_path): - site = tmp_path / "site" - (site / sitefiles.MODELS_DIR).mkdir(parents=True) - store = sitefiles.Store(site) - loose = store.put("model.json", b'{ "a" : 1 }') - doc = {"id": "m", "key": KEY_A, "model": {}, "files": {"model.json": loose}} - (site / "models" / "m.json").write_text(json.dumps(doc), encoding="utf-8") - assert sitefiles.reingest_json(site) == {"changedEntries": 1} - man = json.loads((site / "models" / "m.json").read_text(encoding="utf-8")) - assert sitefiles.entry_text(site, man["files"]["model.json"]) == b'{"a":1}' - - -LIVE2D_BUNDLE = "/* live2d bundle */\n" - - -def fake_player(root, live2d=True, live2d_bundle=True): - files = { - sitefiles.READ_SET_SCRIPT: "// read set\n", - sitefiles.PLAYER_BUNDLES[0]: "/* bundle */\n", - f"{sitefiles.PLAYER_PAGE_DIR}/index.html": '\n', - f"{sitefiles.PLAYER_PAGE_DIR}/chart-list.js": 'import "../../src/element.js";\n', - } - if live2d: - files[f"{sitefiles.LIVE2D_PAGE_DIR}/index.html"] = '\n' - files[f"{sitefiles.LIVE2D_PAGE_DIR}/live2d.js"] = "import '../../src/live2d/define.js'; // @PLAYER_VERSION@\n" - if live2d_bundle: - files[sitefiles.LIVE2D_BUNDLES[0]] = LIVE2D_BUNDLE - files[sitefiles.LIVE2D_BUNDLES[0] + ".map"] = "{}" - for rel, text in files.items(): - p = root / rel - p.parent.mkdir(parents=True, exist_ok=True) - p.write_bytes(text.encode("utf-8")) - return root - - -def test_write_player_copies_the_live2d_page(tmp_path): - site = tmp_path / "site" - r = sitefiles.write_player(site, fake_player(tmp_path / "p")) - assert (r["pageFiles"], r["live2dPageFiles"]) == (2, 2) - version = hashlib.sha256(LIVE2D_BUNDLE.encode()).hexdigest()[:16] - page = site / sitefiles.LIVE2D_PAGE_SITE_DIR - assert (page / "live2d.js").read_text( - encoding="utf-8" - ) == f"import './ournotes-player.live2d.element.min.js'; // {version}\n" - assert (page / "ournotes-player.live2d.element.min.js").read_text(encoding="utf-8") == LIVE2D_BUNDLE - assert (page / "ournotes-player.live2d.element.min.js.map").is_file() - assert (site / "chart-list.js").read_text(encoding="utf-8") == 'import "./ournotes-player.element.min.js";\n' - r = sitefiles.write_player(tmp_path / "site2", fake_player(tmp_path / "p2", live2d=False, live2d_bundle=False)) - assert r["live2dPageFiles"] == 0 and not (tmp_path / "site2" / sitefiles.LIVE2D_PAGE_SITE_DIR).exists() - with pytest.raises(ConfigError, match="has the Live2D page but not"): - sitefiles.write_player(tmp_path / "site3", fake_player(tmp_path / "p3", live2d_bundle=False)) - player = fake_player(tmp_path / "p4") - (player / sitefiles.LIVE2D_PAGE_DIR / "extra.js").write_bytes(b'import "../../src/element.js";\n') - with pytest.raises(RuntimeError, match="beyond LIVE2D_PAGE_IMPORTS"): - sitefiles.write_player(tmp_path / "site4", player) - - -def test_player_pages_never_overwrite_site_data(tmp_path): - player = fake_player(tmp_path / "p") - (player / sitefiles.PLAYER_PAGE_DIR / "models.json").write_bytes(b"{}") - with pytest.raises(RuntimeError, match="would overwrite the site data"): - sitefiles.write_player(tmp_path / "site", player) - - -def put_manifest(site, mid, key, model=None, doc=None): - """A model manifest with a model.json only (`doc`, default a current one: MODEL_FORMAT, motionSync).""" - doc = {"format": webmodel.MODEL_FORMAT, "key": key, "motionSync": False} if doc is None else doc - entry = sitefiles.Store(site).put("model.json", json.dumps(doc).encode()) - (site / "models").mkdir(parents=True, exist_ok=True) - (site / "models" / f"{mid}.json").write_text( - json.dumps({"key": key, "model": model or {}, "files": {"model.json": entry}}), encoding="utf-8" - ) - - -def test_build_skips_existing_models_unless_forced(tmp_path, monkeypatch): - site = tmp_path / "site" - put_manifest(site, "adv_model_a", KEY_A) - done = [] - - def fake_task(mid, key, job, data=None): - done.append(mid) - put_manifest(site, mid, key) - return {"id": mid, "ok": True, "files": 0, "bytes": 0} - - monkeypatch.setattr(webmodel, "model_task", fake_task) - monkeypatch.setattr(webmodel, "_open", lambda cfg: (None, None)) - models = {"adv_model_a": KEY_A, "adv_model_b": KEY_B} - r = webmodel.build(site, models, Config(), fake_player(tmp_path / "p"), workers=1) - assert done == ["adv_model_b"] and r["modelsSkipped"] == ["adv_model_a"] and r["models"] == 2 - assert [m["id"] for m in r["modelsBuilt"]] == ["adv_model_b"] and r["live2dPageFiles"] == 2 - assert r["modelNames"] is None # no master data: no names - r = webmodel.build(site, models, Config(), fake_player(tmp_path / "p"), force=True, workers=1) - assert done == ["adv_model_b", "adv_model_a", "adv_model_b"] and r["modelsSkipped"] == [] - assert r["modelsRebuilt"] == [] # force: every model, none of them for a reason - with pytest.raises(ValueError, match="has the id"): - webmodel.build(site, {"other": KEY_A}, Config(), fake_player(tmp_path / "p"), workers=1) - - -def test_build_rebuilds_outdated_manifests(tmp_path, monkeypatch): - """An existing manifest whose model.json is older than MODEL_FORMAT, has no motionSync or cannot be read is built - again without force; a current one is skipped.""" - site = tmp_path / "site" - keys = {f"m{i}": f"Character/Live2D/g/m{i}/model/m{i}" for i in range(5)} - put_manifest(site, "m0", keys["m0"]) # current - put_manifest(site, "m1", keys["m1"], doc={"format": 1, "key": keys["m1"]}) # format 1 - put_manifest(site, "m2", keys["m2"], doc={"format": webmodel.MODEL_FORMAT, "key": keys["m2"]}) # no motionSync - (site / "models" / "m3.json").write_text( - json.dumps({"key": keys["m3"], "model": {}, "files": {}}), encoding="utf-8" - ) # no model.json - done = [] - - def fake_task(mid, key, job, data=None): - done.append(mid) - put_manifest(site, mid, key) - return {"id": mid, "ok": True, "files": 1, "bytes": 1} - - monkeypatch.setattr(webmodel, "model_task", fake_task) - monkeypatch.setattr(webmodel, "_open", lambda cfg: (None, None)) - assert webmodel.outdated(site, "m0") is None and webmodel.outdated(site, "m1") == "outdated" - r = webmodel.build(site, keys, Config(), fake_player(tmp_path / "p"), workers=1) - assert done == ["m1", "m2", "m3", "m4"] and r["modelsSkipped"] == ["m0"] - assert r["modelsRebuilt"] == [ - {"id": "m1", "reason": "outdated"}, - {"id": "m2", "reason": "outdated"}, - {"id": "m3", "reason": "unreadable"}, - ] - assert [m["id"] for m in r["modelsBuilt"]] == ["m1", "m2", "m3", "m4"] - r = webmodel.build(site, keys, Config(), fake_player(tmp_path / "p"), workers=1) # all current now - assert r["modelsSkipped"] == list(keys) and r["modelsRebuilt"] == [] and len(done) == 4 - - -# ---------------------------------------------------------------- command line -def run(argv, capsys): - code = 0 - try: - cli.main(argv) - except SystemExit as e: - code = e.code if isinstance(e.code, int) else 1 - out, err = capsys.readouterr() - return code, out, err - - -def test_web_argument_shape(): - p = cli.build_parser() - a = p.parse_args(["--json", "site", "build", "-o", "s", "--pair", "1:easy", "--live2d", "m1", "--live2d", KEY_B]) - assert a.pair == [(1, "easy")] and a.live2d == ["m1", KEY_B] and not a.all_live2d - a = p.parse_args(["--json", "site", "build", "-o", "s", "--all", "--all-live2d", "--force"]) - assert a.all and a.all_live2d and a.live2d is None and a.force - for bad in (["--live2d", "m", "--all-live2d"], ["--pair", "1:easy", "--all"], ["--all", "--player-only"]): - with pytest.raises(SystemExit): - p.parse_args(["--json", "site", "build", "-o", "s", *bad]) - - -@pytest.mark.parametrize( - "argv, message", - [ - ([], "--all-live2d"), - (["--player-only", "--live2d", "m"], "leave out --live2d"), - (["--reingest-json", "--all-live2d"], "leave out --live2d"), - ], -) -def test_web_selection_errors(tmp_path, capsys, argv, message): - code, _, err = run(["--json", "site", "build", "-o", str(tmp_path / "s"), *argv], capsys) - assert code == 2 and message in err - - -class FakeCatalog: - def keys(self, prefix=""): - return [k for k in (KEY_A, KEY_B, "Live/Score/1") if k.startswith(prefix)] - - -@pytest.mark.parametrize("failed, code", [([], 0), ([{"id": "adv_model_b", "stage": "export", "error": "x"}], 1)]) -def test_web_builds_models_then_charts(tmp_path, capsys, monkeypatch, failed, code): - calls = [] - monkeypatch.setattr(Session, "catalog", lambda self, bundles=True, region=None: FakeCatalog()) - - def fake_models(out, models, cfg, player, **kw): - calls.append(("models", models, kw)) - return {"site": str(out), "modelsBuilt": [], "modelsFailed": failed, "modelsSkipped": []} - - def fake_charts(out, pairs, cfg, player, fmt, **kw): - calls.append(("charts", pairs, kw)) - return {"site": str(out), "built": [], "failed": [], "skipped": []} - - monkeypatch.setattr(webmodel, "build", fake_models) - monkeypatch.setattr(web, "build", fake_charts) - monkeypatch.setattr(web, "unknown_pairs", lambda cfg, pairs, regions=None: []) # no master data here - (tmp_path / "base.apk").write_bytes(b"") - code_, out, _ = run( - [ - "--apk", - str(tmp_path / "base.apk"), - "--json", - "site", - "build", - "-o", - str(tmp_path / "s"), - "--player", - str(fake_player(tmp_path / "p")), - "--pair", - "7:hard", - "--live2d", - "adv_model_b", - "--live2d", - KEY_A, - "--force", - ], - capsys, - ) - assert code_ == code - assert json.loads(out)["modelsFailed"] == failed - (m, models, mkw), (c, pairs, ckw) = calls - assert (m, c) == ("models", "charts") - assert models == {"adv_model_a": KEY_A, "adv_model_b": KEY_B} and mkw["force"] is True - assert mkw["encoding"] == "gzip" - assert pairs == [(7, "hard")] and ckw["force"] is True - - -def test_web_unknown_model_is_a_usage_error(tmp_path, capsys, monkeypatch): - monkeypatch.setattr(Session, "catalog", lambda self, bundles=True, region=None: FakeCatalog()) - (tmp_path / "base.apk").write_bytes(b"") - code, _, err = run( - [ - "--apk", - str(tmp_path / "base.apk"), - "--json", - "site", - "build", - "-o", - str(tmp_path / "s"), - "--player", - str(fake_player(tmp_path / "p")), - "--live2d", - "nope", - ], - capsys, - ) - assert code == 2 and "not a Live2D model of the catalog: nope" in err - - -# ---------------------------------------------------------------- the runtime export -def test_webmodel_uses_the_public_runtime_export(tmp_path, monkeypatch): - tree = ast.parse(inspect.getsource(webmodel)) - used = { - n.attr - for n in ast.walk(tree) - if isinstance(n, ast.Attribute) and isinstance(n.value, ast.Name) and n.value.id == "live2d" - } - assert "export_runtime" in used and not [a for a in used if a.startswith("_")] - assert not hasattr(live2d, "_extract_runtime") - rt = live2d.RuntimeExport({"name": "m", "moc3": "m.moc3"}, exporter=None, env=None, graph=None, classes={}) - monkeypatch.setattr(live2d, "export_runtime", lambda cat, key, out_dir: rt) - assert live2d.extract_runtime(None, KEY_A, tmp_path) == {"name": "m", "moc3": "m.moc3"} - - -def test_runtime_export_needs_the_apk(tmp_path): - with pytest.raises(ConfigError, match="paths.apk|NNNOTES_PATHS_APK"): - live2d.export_runtime(SimpleNamespace(apk=None), KEY_A, tmp_path) - - -# ---------------------------------------------------------------- names from the master data -def name_row(tid, ja, en, zh=None): - return { - "_id": tid, - "_japanese": ja, - "_english": en, - "_traditionalChinese": zh or ja, - "_simplifiedChinese": zh or ja, - "_korean": "", - } - - -def write_master(md, costumes, characters=None, texts=None): - md.mkdir(parents=True, exist_ok=True) - tables = { - "MasterCharacterCostume": costumes, - "MasterCharacter": characters - if characters is not None - else [ - {"_id": 1, "_nameTextID": "Name_A"}, - {"_id": 2, "_nameTextID": "Name_B"}, - {"_id": 3, "_nameTextID": "Gone"}, - ], - "MasterText": texts - if texts is not None - else [name_row("Name_A", "エー", "Ay", "艾"), name_row("Name_B", "ビー", "Bee")], - } - for name, rows in tables.items(): - (md / f"{name}.json").write_text(json.dumps({"_allData": rows}, ensure_ascii=False), encoding="utf-8") - return md - - -def costume(cid, path, char): - return {"_id": cid, "_characterID": char, "_costumeID": cid % 1000, "_isDefault": False, "_live2dPath": path} - - -PATH_A, PATH_B = KEY_A.removeprefix(webmodel.LIVE2D_PREFIX), KEY_B.removeprefix(webmodel.LIVE2D_PREFIX) - - -def test_model_names_from_the_costume_rows(tmp_path): - md = write_master( - tmp_path / "m", - [ - costume(1001, PATH_A, 1), - costume(1002, PATH_A, 1), # two costumes, one model - costume(2001, PATH_B, 2), - costume(2002, "002_adv/shared/model/shared", 2), - costume(3001, "002_adv/shared/model/shared", 1), - costume(3002, "003_adv/c/model/c", 3), # no name text - {"_id": 9, "_characterID": 1, "_live2dPath": ""}, - ], - ) - names = webmodel.model_names(md, "zh-Hant") - assert names == { - KEY_A: {"character": 1, "names": {"ja": "エー", "en": "Ay", "zh-Hant": "艾", "zh-Hans": "艾"}, "label": "艾"}, - KEY_B: { - "character": 2, - "names": {"ja": "ビー", "en": "Bee", "zh-Hant": "ビー", "zh-Hans": "ビー"}, - "label": "ビー", - }, - } - assert "label" not in webmodel.model_names(md)[KEY_A] - assert "label" not in webmodel.model_names(md, "ko")[KEY_A] # no Korean text - assert webmodel.model_facts(KEY_A, {"canvas": {}, "textures": [], "nodes": 0}, names[KEY_A]) == { - "group": "001_adv", - "canvas": {}, - "textures": 0, - "nodes": 0, - **names[KEY_A], - } - - -def test_master_names_follow_the_settings(tmp_path): - md = write_master(tmp_path / "m", [costume(1001, PATH_A, 1)]) - assert webmodel.master_names(Config()) is None - cfg = Config(overrides={("paths", "master"): str(md), ("catalog", "language"): "en"}) - assert webmodel.master_names(cfg) == { - KEY_A: {"character": 1, "names": {"ja": "エー", "en": "Ay", "zh-Hant": "艾", "zh-Hans": "艾"}, "label": "Ay"} - } - (md / "MasterCharacterCostume.json").unlink() - logged = [] - assert webmodel.master_names(cfg, log=logged.append) is None - assert logged == ["model names left out: MasterCharacterCostume.json not in the master data"] - with pytest.raises(ConfigError, match="paths.master"): - webmodel.master_names(Config(overrides={("paths", "master"): str(tmp_path / "absent")})) - - -def test_build_adds_names_and_refreshes_skipped_manifests(tmp_path, monkeypatch): - site = tmp_path / "site" - (site / "models").mkdir(parents=True) - put_manifest(site, "adv_model_a", KEY_A, model={"group": "001_adv", "character": 9, "label": "old"}) - jobs = [] - - def fake_task(mid, key, job, data=None): - jobs.append((mid, job["names"].get(key))) - put_manifest(site, mid, key) - return {"id": mid, "ok": True, "files": 0, "bytes": 0} - - monkeypatch.setattr(webmodel, "model_task", fake_task) - monkeypatch.setattr(webmodel, "_open", lambda cfg: (None, None)) - md = write_master(tmp_path / "m", [costume(1001, PATH_A, 1), costume(2001, PATH_B, 2)]) - cfg = Config(overrides={("paths", "master"): str(md), ("catalog", "language"): "ja"}) - r = webmodel.build(site, {"adv_model_a": KEY_A, "adv_model_b": KEY_B}, cfg, fake_player(tmp_path / "p"), workers=1) - assert r["modelNames"] == 2 and r["modelsSkipped"] == ["adv_model_a"] - assert jobs == [ - ( - "adv_model_b", - { - "character": 2, - "names": {"ja": "ビー", "en": "Bee", "zh-Hant": "ビー", "zh-Hans": "ビー"}, - "label": "ビー", - }, - ) - ] - man = json.loads((site / "models" / "adv_model_a.json").read_text(encoding="utf-8")) - assert man["model"] == { - "group": "001_adv", - "character": 1, - "names": {"ja": "エー", "en": "Ay", "zh-Hant": "艾", "zh-Hans": "艾"}, - "label": "エー", - } - index = json.loads((site / "models.json").read_text(encoding="utf-8")) - assert [(m["id"], m.get("label")) for m in index["models"]] == [("adv_model_a", "エー"), ("adv_model_b", None)] - # the same names again: the skipped manifest is not rewritten - before = (site / "models" / "adv_model_a.json").read_bytes() - assert webmodel.refresh_names(site, "adv_model_a", webmodel.model_names(md, "ja")[KEY_A]) is False - assert (site / "models" / "adv_model_a.json").read_bytes() == before - # a key the master data no longer maps loses the fields - assert webmodel.refresh_names(site, "adv_model_a", None) is True - man = json.loads((site / "models" / "adv_model_a.json").read_text(encoding="utf-8")) - assert man["model"] == {"group": "001_adv"} - - -# ---------------------------------------------------------------- model.json and the model sources of story.build -class FakeShader: - def __init__(self, name): - self.name = name - self.assets_file = SimpleNamespace(name=f"CAB-{name}") - - -def fake_export(monkeypatch, motion_sync=False): - """export_model's inputs made up: the runtime export writes fake_model's files, the player's mask materials are - MASKS and the shader dump indexes SHADER_INDEX (fake_model wrote its files); -> the player data.""" - - def export_runtime(cat, key, out_dir): - name = webmodel.model_id(key) - fake_model(out_dir, name, key=key, motion_sync=motion_sync) - prefab = json.loads((out_dir / f"{name}.prefab.json").read_text(encoding="utf-8")) - summary = { - "name": name, - "moc3": f"{name}.moc3", - "prefab": f"{name}.prefab.json", - "moc3Bytes": 10, - "textures": ["textures/p0-00.png", "textures/p1-11.png"], - "nodes": len(prefab["nodes"]), - } - return live2d.RuntimeExport(summary, SimpleNamespace(shaders={"S/Lit": FakeShader("S/Lit")}), None, None, {}) - - class FakeExporter: - def __init__(self, cat, out_dir, player=None): - self.shaders = {"S/Mask": FakeShader("S/Mask")} - - def material(self, path): - return MASKS[{v: k for k, v in webmodel.RESOURCES.items()}[path]] - - def dump_objects(objs, sdir, name, index): - index.extend(r for r in SHADER_INDEX if r["name"] == objs[0].name) - - def write_index(index, sdir): - jsonio.write_json(sdir / "shaders.json", index) - - monkeypatch.setattr(live2d, "export_runtime", export_runtime) - monkeypatch.setattr(webmodel, "Exporter", FakeExporter) - monkeypatch.setattr(webmodel.shader_mod, "dump_objects", dump_objects) - monkeypatch.setattr(webmodel.shader_mod, "write_index", write_index) - return SimpleNamespace(resource=lambda path: path) - - -def model_files(name): - return [f.replace("m.", f"{name}.") for f in MASKED_FILES] - - -@pytest.mark.parametrize("motion_sync", [False, True]) -def test_export_model_writes_model_json(tmp_path, monkeypatch, motion_sync): - player = fake_export(monkeypatch, motion_sync) - r = webmodel.export_model(None, player, KEY_A, tmp_path / "x") - doc = json.loads((tmp_path / "x" / webmodel.MODEL_INDEX).read_text(encoding="utf-8")) - assert doc == { - "format": 2, - "name": "adv_model_a", - "key": KEY_A, - "moc3": "adv_model_a.moc3", - "prefab": "adv_model_a.prefab.json", - "textures": ["textures/p0-00.png"], - "canvas": {"width": 1.0}, - "shaders": "shaders/shaders.json", - "resources": MASKS, - "motionSync": motion_sync, - } - assert r["files"] == model_files("adv_model_a") - - -def site_with(tmp_path, *models): - """A site with the model manifests of fake_model for each (key, motion_sync).""" - site = tmp_path / "site" - (site / sitefiles.MODELS_DIR).mkdir(parents=True, exist_ok=True) - for key, ms in models: - mid = webmodel.model_id(key) - webmodel.ingest( - sitefiles.Store(site), site, mid, key, *fake_model(tmp_path / mid, mid, key=key, motion_sync=ms) - ) - return site - - -def test_site_models_read_model_json(tmp_path): - site = site_with(tmp_path, (KEY_A, True), (KEY_B, False)) - src = webmodel.SiteModels(site) - assert src.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert src.ensure(KEY_B) == {"id": "adv_model_b", "motionSync": False} - assert src.story_fields(tmp_path / "story") == {} - with pytest.raises(RuntimeError, match="no model manifest models/c.json in the site"): - src.ensure("Character/Live2D/g/c/model/c") - # a model.json without motionSync (a model built before it had one) - man = json.loads((site / "models" / "adv_model_b.json").read_text(encoding="utf-8")) - doc = { - k: v for k, v in json.loads(sitefiles.entry_text(site, man["files"]["model.json"])).items() if k != "motionSync" - } - man["files"]["model.json"] = sitefiles.Store(site).put("model.json", json.dumps({**doc, "format": 1}).encode()) - (site / "models" / "adv_model_b.json").write_text(json.dumps(man), encoding="utf-8") - with pytest.raises(RuntimeError, match="format 1 has no motionSync; export the model again"): - src.ensure(KEY_B) - # the manifest of another key with the same id - with pytest.raises(RuntimeError, match="not of Character/Live2D/other/adv_model_a"): - src.ensure("Character/Live2D/other/adv_model_a/model/adv_model_a") - - -def dir_files(d): - return sorted(p.relative_to(d).as_posix() for p in d.rglob("*") if p.is_file()) - - -def test_model_dir_exports_each_model_once(tmp_path, monkeypatch): - player = fake_export(monkeypatch, motion_sync=True) - calls, real = [], webmodel.export_model - - def counted(*a): - calls.append(a[2]) - return real(*a) - - monkeypatch.setattr(webmodel, "export_model", counted) - root = tmp_path / "out" / "live2d" - a = webmodel.ModelDir(None, player, root) - assert a.ensure(KEY_A) == a.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert calls == [KEY_A] and (a.built, a.skipped) == (["adv_model_a"], []) - d = root / "adv_model_a" - # the files of the model's manifest: no unused atlas page, no other platform or GLES 3.1 program - assert dir_files(root) == [f"adv_model_a/{f}" for f in model_files("adv_model_a")] - index = json.loads((d / "shaders/shaders.json").read_text(encoding="utf-8")) - assert [v["file"] for r in index for v in r["variants"]] == [ - "S_Lit/gles3/s0p0_vertex_0.glsl", - "S_Lit/gles3/s0p0_vertex_1.glsl", - "S_Lit/gles3/s0p0_vertex_5.glsl", - "S_Lit/gles3/s0p0_vertex_6.glsl", - "S_Mask/gles3/s0p0_vertex_0.glsl", - ] - assert (d / "adv_model_a.moc3").read_bytes() == b"MOC3 bytes" - assert a.story_fields(tmp_path / "out" / "s1") == {"modelsDir": "../live2d"} - # another story's source: the directory is used as it is - before = {f: (d / f).read_bytes() for f in dir_files(d)} - b = webmodel.ModelDir(None, player, root) - assert b.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert calls == [KEY_A] and (b.built, b.skipped) == ([], ["adv_model_a"]) - assert b.story_fields(tmp_path / "out" / "s2") == {"modelsDir": "../live2d"} - # force: exported again, once per source; the same files - c = webmodel.ModelDir(None, player, root, force=True) - c.ensure(KEY_A) - c.ensure(KEY_A) - assert calls == [KEY_A, KEY_A] and c.built == ["adv_model_a"] - assert {f: (d / f).read_bytes() for f in dir_files(d)} == before - - def failing(*a): - raise RuntimeError("export failed") - - # a failed export leaves no directory; a directory that is not a model export is refused - monkeypatch.setattr(webmodel, "export_model", failing) - with pytest.raises(RuntimeError, match="export failed"): - webmodel.ModelDir(None, player, root).ensure(KEY_B) - assert sorted(p.name for p in root.iterdir()) == ["adv_model_a"] - (root / "adv_model_b").mkdir() - with pytest.raises(RuntimeError, match="not a model directory"): - webmodel.ModelDir(None, player, root).ensure(KEY_B) - - -@pytest.mark.parametrize("force", [False, True]) -def test_model_dir_shared_by_processes_keeps_one_whole_model(tmp_path, monkeypatch, force): - """Another process (another ModelDir on the same root) installs the model while this one exports it: without - force the first installed copy is kept and this export dropped, with force this export replaces it; either way - one whole model directory and no temporary directory is left.""" - player = fake_export(monkeypatch, motion_sync=True) - root, real, other = tmp_path / "live2d", webmodel.export_model, [] - - def racing(*a): - if not other: - other.append(webmodel.ModelDir(None, player, root, force=force)) - other[0].ensure(KEY_A) - return real(*a) - - monkeypatch.setattr(webmodel, "export_model", racing) - a = webmodel.ModelDir(None, player, root, force=force) - assert a.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert (a.built, a.skipped) == ((["adv_model_a"], []) if force else ([], ["adv_model_a"])) - assert other[0].built == ["adv_model_a"] - assert [p.name for p in root.iterdir()] == ["adv_model_a"] - assert dir_files(root) == [f"adv_model_a/{f}" for f in model_files("adv_model_a")] - - -def test_model_dir_exports_a_model_moved_aside_after_the_check(tmp_path, monkeypatch): - """The model directory existed when checked but is gone when read (a forced export of another process moved it - aside): exported here.""" - player = fake_export(monkeypatch, motion_sync=True) - root = tmp_path / "live2d" - d, real, seen = root / "adv_model_a", webmodel.Path.exists, [] - - def exists(p): - if p == d and not seen: - seen.append(p) - return True - return real(p) - - monkeypatch.setattr(webmodel.Path, "exists", exists) - a = webmodel.ModelDir(None, player, root) - assert a.ensure(KEY_A) == {"id": "adv_model_a", "motionSync": True} - assert seen and (a.built, a.skipped) == (["adv_model_a"], []) - assert dir_files(root) == [f"adv_model_a/{f}" for f in model_files("adv_model_a")] - - -def test_story_command_exports_its_models_next_to_the_story(tmp_path, capsys, monkeypatch): - from nnnotes.content import story - - player = fake_export(monkeypatch) - monkeypatch.setattr(Session, "catalog", lambda self, bundles=True, region=None: None) - monkeypatch.setattr(Session, "apk", lambda self, region=None: None) - monkeypatch.setattr(nnnotes_player, "PlayerData", lambda apk: player) - - def fake_build(cat, md, pl, adv_id, out_dir, *, models, **kw): - index = {"models": {KEY_A: models.ensure(KEY_A)["id"]}, **models.story_fields(out_dir)} - out_dir.mkdir(parents=True) - jsonio.write_json(out_dir / "story.json", index) - return index - - monkeypatch.setattr(story, "build", fake_build) - md = tmp_path / "master" - md.mkdir() - (md / "MasterAdv.json").write_text(json.dumps({"_allData": [{"_id": 1}]}), encoding="utf-8") - base = ["--json", "--master", str(md), "--language", "ja", "export", "story", "1"] - out = tmp_path / "out" - code, o, err = run([*base, "-o", str(out / "s1")], capsys) - assert code == 0, err - r = json.loads(o) - assert r["models"] == {KEY_A: "adv_model_a"} and r["modelsDir"] == "../live2d" - assert (r["modelsBuilt"], r["modelsSkipped"]) == (["adv_model_a"], []) - assert json.loads((out / "s1/story.json").read_text(encoding="utf-8"))["modelsDir"] == "../live2d" - assert (out / "live2d/adv_model_a" / webmodel.MODEL_INDEX).is_file() - code, o, _ = run([*base, "-o", str(out / "s2")], capsys) # a second story: the model is reused - r = json.loads(o) - assert code == 0 and (r["modelsBuilt"], r["modelsSkipped"]) == ([], ["adv_model_a"]) - code, o, _ = run([*base, "-o", str(out / "s3"), "--models", str(tmp_path / "m"), "--force"], capsys) - r = json.loads(o) - assert code == 0 and r["modelsDir"] == "../../m" and r["modelsBuilt"] == ["adv_model_a"] - assert (tmp_path / "m/adv_model_a" / webmodel.MODEL_INDEX).is_file() diff --git a/tests/source/test_catalog.py b/tests/source/test_catalog.py index 795ce92..0349c10 100644 --- a/tests/source/test_catalog.py +++ b/tests/source/test_catalog.py @@ -102,34 +102,34 @@ def make_apk(path): return path -def test_closure_fetch_and_cache(tmp_path): +def test_closure_fetch_and_download(tmp_path): cdn = make_cdn(tmp_path / "cdn") - cat = Catalog(remote_catalog(), tmp_path / "cache", cdn=cdn + "/", bundle_key=KEY) + cat = Catalog(remote_catalog(), tmp_path / "downloads", cdn=cdn + "/", bundle_key=KEY) assert cat.keys("Char/") == ["Char/A", "Char/B"] assert cat.has("Story/物語") and not cat.has("Char/C") assert sorted(b.name for b in cat.resolve("Char/A")) == ["a_01.bundle", "b_02.bundle"] paths = cat.fetch_key("Char/A") assert sorted(p.name for p in paths) == ["a_01.bundle", "b_02.bundle"] for p in paths: - assert p.parent == tmp_path / "cache" / "bundles" + assert p.parent == tmp_path / "downloads" / "bundles" assert p.read_bytes() == synth.fake_bundle(p.name) - # cached bundles are not fetched again + # downloaded bundles are not fetched again for p in (tmp_path / "cdn" / "asset" / "Android").iterdir(): p.unlink() - assert cat.fetch_key("Char/B") == [tmp_path / "cache" / "bundles" / "b_02.bundle"] + assert cat.fetch_key("Char/B") == [tmp_path / "downloads" / "bundles" / "b_02.bundle"] with pytest.raises(KeyError): cat.resolve("Char/C") def test_encrypted_bundle_needs_key(tmp_path): - cat = Catalog(remote_catalog(), tmp_path / "cache", cdn=make_cdn(tmp_path / "cdn")) + cat = Catalog(remote_catalog(), tmp_path / "downloads", cdn=make_cdn(tmp_path / "cdn")) with pytest.raises(RuntimeError, match="no bundle key"): cat.fetch_key("Char/B") def test_apk_catalog_is_merged(tmp_path): apk = make_apk(tmp_path / "base.apk") - cat = Catalog(remote_catalog(), tmp_path / "cache", cdn=make_cdn(tmp_path / "cdn"), bundle_key=KEY, apk=apk) + cat = Catalog(remote_catalog(), tmp_path / "downloads", cdn=make_cdn(tmp_path / "cdn"), bundle_key=KEY, apk=apk) assert "Shared/S" in cat.keys() (b,) = cat.resolve("Shared/S") assert not b.remote and b.offset >= APK_OFFSET_BASE @@ -141,7 +141,7 @@ def test_apk_catalog_is_merged(tmp_path): def test_apk_bundles_skipped_without_apk(tmp_path): - cat = Catalog(synth.CatalogWriter().build(APK_ENTRIES), tmp_path / "cache", bundle_key=KEY) + cat = Catalog(synth.CatalogWriter().build(APK_ENTRIES), tmp_path / "downloads", bundle_key=KEY) assert cat.fetch_key("Shared/S") == [] @@ -149,13 +149,13 @@ def test_load_downloads_catalog_once(tmp_path): cdn_root = tmp_path / "cdn" (cdn_root / "asset" / "Android").mkdir(parents=True) (cdn_root / "asset" / "Android" / "catalog_main_xx.bin").write_bytes(remote_catalog()) - cat = Catalog.load("xx", tmp_path / "cache", cdn=cdn_root.as_uri()) - assert (tmp_path / "cache" / "catalog_main_xx.bin").read_bytes() == remote_catalog() + cat = Catalog.load("xx", tmp_path / "downloads", cdn=cdn_root.as_uri()) + assert (tmp_path / "downloads" / "catalog_main_xx.bin").read_bytes() == remote_catalog() assert cat.keys("Char/") == ["Char/A", "Char/B"] (cdn_root / "asset" / "Android" / "catalog_main_xx.bin").unlink() - assert Catalog.load("xx", tmp_path / "cache").keys("Story/") == ["Story/物語"] + assert Catalog.load("xx", tmp_path / "downloads").keys("Story/") == ["Story/物語"] with pytest.raises(FileNotFoundError): - Catalog.load("yy", tmp_path / "cache") + Catalog.load("yy", tmp_path / "downloads") def test_downloads_keep_one_connection_per_thread(monkeypatch): diff --git a/tests/source/test_catalog_lazy.py b/tests/source/test_catalog_lazy.py index 60aec10..048de6d 100644 --- a/tests/source/test_catalog_lazy.py +++ b/tests/source/test_catalog_lazy.py @@ -43,12 +43,12 @@ def f(): return f -def test_cached_bundles_read_no_settings(tmp_path): +def test_downloaded_bundles_read_no_settings(tmp_path): cdn = cdn_tree(tmp_path / "cdn") - first = Catalog(synth.CatalogWriter().build(ENTRIES), tmp_path / "cache", cdn=cdn, bundle_key=KEY) + first = Catalog(synth.CatalogWriter().build(ENTRIES), tmp_path / "downloads", cdn=cdn, bundle_key=KEY) (p,) = first.fetch_key("Char/A") again = Catalog( - synth.CatalogWriter().build(ENTRIES), tmp_path / "cache", cdn=never("cdn"), bundle_key=never("bundle key") + synth.CatalogWriter().build(ENTRIES), tmp_path / "downloads", cdn=never("cdn"), bundle_key=never("bundle key") ) assert again.fetch_key("Char/A") == [p] and again.fetch_key("Char/B") == [p] assert p.read_bytes() == synth.fake_bundle("a_01.bundle") @@ -59,7 +59,7 @@ def test_settings_are_read_once_when_a_download_needs_them(tmp_path): calls = [] cat = Catalog( synth.CatalogWriter().build(ENTRIES), - tmp_path / "cache", + tmp_path / "downloads", cdn=lambda: calls.append("cdn") or cdn, bundle_key=lambda: calls.append("key") or KEY, ) @@ -74,19 +74,23 @@ def test_settings_are_read_once_when_a_download_needs_them(tmp_path): def test_missing_setting_names_the_file_and_the_setting(tmp_path): cdn = cdn_tree(tmp_path / "cdn") cat = Catalog( - synth.CatalogWriter().build(ENTRIES), tmp_path / "cache", cdn=missing("servers.zz.cdn"), bundle_key=KEY + synth.CatalogWriter().build(ENTRIES), tmp_path / "downloads", cdn=missing("servers.zz.cdn"), bundle_key=KEY ) with pytest.raises(ConfigError) as e: cat.fetch_key("Char/A") - assert str(e.value) == "a_01.bundle is not in the cache: setting servers.zz.cdn is not set" - cat = Catalog(synth.CatalogWriter().build(ENTRIES), tmp_path / "cache", cdn=cdn, bundle_key=missing("bundle.key")) - with pytest.raises(ConfigError, match="^a_01.bundle is not in the cache: setting bundle.key is not set$"): + assert str(e.value) == "a_01.bundle is not downloaded: setting servers.zz.cdn is not set" + cat = Catalog( + synth.CatalogWriter().build(ENTRIES), tmp_path / "downloads", cdn=cdn, bundle_key=missing("bundle.key") + ) + with pytest.raises(ConfigError, match="^a_01.bundle is not downloaded: setting bundle.key is not set$"): cat.fetch_key("Char/A") def test_missing_key_fails_before_the_download(tmp_path): gone = (tmp_path / "no-such-cdn").as_uri() # a download would fail with URLError - cat = Catalog(synth.CatalogWriter().build(ENTRIES), tmp_path / "cache", cdn=gone, bundle_key=missing("bundle.key")) + cat = Catalog( + synth.CatalogWriter().build(ENTRIES), tmp_path / "downloads", cdn=gone, bundle_key=missing("bundle.key") + ) with pytest.raises(ConfigError, match="bundle.key"): cat.fetch_key("Char/A") @@ -107,7 +111,7 @@ def test_apk_bundles_read_the_key_only_when_encrypted(tmp_path): z.writestr("assets/aa/Android/p_04.bundle", synth.fake_bundle("p_04.bundle")) cat = Catalog( synth.CatalogWriter().build(ENTRIES), - tmp_path / "cache", + tmp_path / "downloads", cdn=never("cdn"), bundle_key=missing("bundle.key"), apk=apk, @@ -115,19 +119,19 @@ def test_apk_bundles_read_the_key_only_when_encrypted(tmp_path): (p,) = cat.fetch_key("Shared/P") # plain: no key assert p.read_bytes() == synth.fake_bundle("p_04.bundle") assert cat.apk_bundle("p_04").read_bytes() == synth.fake_bundle("p_04.bundle") - with pytest.raises(ConfigError, match="^s_03.bundle is not in the cache: setting bundle.key"): + with pytest.raises(ConfigError, match="^s_03.bundle is not downloaded: setting bundle.key"): cat.fetch_key("Shared/S") - with pytest.raises(ConfigError, match="^s_03.bundle is not in the cache"): + with pytest.raises(ConfigError, match="^s_03.bundle is not downloaded"): cat.apk_bundle("s_03") -def test_load_reads_the_cdn_only_for_an_uncached_catalog(tmp_path): +def test_load_reads_the_cdn_only_for_a_catalog_not_downloaded(tmp_path): cdn = cdn_tree(tmp_path / "cdn") - with pytest.raises(ConfigError, match="^catalog_main_xx.bin is not in the cache: setting servers.zz.cdn"): - Catalog.load("xx", tmp_path / "cache", cdn=missing("servers.zz.cdn")) + with pytest.raises(ConfigError, match="^catalog_main_xx.bin is not downloaded: setting servers.zz.cdn"): + Catalog.load("xx", tmp_path / "downloads", cdn=missing("servers.zz.cdn")) calls = [] - Catalog.load("xx", tmp_path / "cache", cdn=lambda: calls.append(1) or cdn) - cat = Catalog.load("xx", tmp_path / "cache", cdn=never("cdn"), bundle_key=never("bundle key")) + Catalog.load("xx", tmp_path / "downloads", cdn=lambda: calls.append(1) or cdn) + cat = Catalog.load("xx", tmp_path / "downloads", cdn=never("cdn"), bundle_key=never("bundle key")) assert calls == [1] and cat.keys("Char/") == ["Char/A", "Char/B"] @@ -167,14 +171,6 @@ def refuse(a, b): assert list(tmp_path.iterdir()) == [] -def test_disk_cache_entries_use_the_short_temporary_name(tmp_path): - from nnnotes.core import cache - - p = tmp_path / "ab" / ("c" * 64) - assert cache._disk_write(p, b"payload") and cache._disk_read(p) == b"payload" - assert sorted(x.name for x in p.parent.iterdir()) == [p.name] - - # ---------------------------------------------------------------- the command line def run(argv, capsys): code = 0 @@ -186,18 +182,19 @@ def run(argv, capsys): return code, out, err -def test_pull_of_cached_bundles_needs_no_region_cdn_or_key(tmp_path, capsys, monkeypatch): +def test_pull_of_downloaded_bundles_needs_no_region_cdn_or_key(tmp_path, capsys, monkeypatch): catbin = tmp_path / "catalog_main_xx.bin" catbin.write_bytes(synth.CatalogWriter().build(ENTRIES)) - base = ["--catalog", str(catbin), "--cache", str(tmp_path / "cache")] + base = ["--catalog", str(catbin), "--depot", str(tmp_path / "depot")] monkeypatch.setenv("NNNOTES_BUNDLE_KEY", synth.BUNDLE_KEY.hex()) monkeypatch.setenv("NNNOTES_BUNDLE_NONCE_SEED", synth.BUNDLE_SEED.hex()) monkeypatch.setenv("NNNOTES_SERVERS_ZZ_CDN", cdn_tree(tmp_path / "cdn")) - code, out, err = run(base + ["--region", "zz", "bundle", "pull", "Char/A"], capsys) + pull = ["bundle", "pull", "-o", str(tmp_path / "out")] + code, out, err = run(base + ["--region", "zz", *pull, "Char/A"], capsys) assert code == 0, err for name in ("NNNOTES_BUNDLE_KEY", "NNNOTES_BUNDLE_NONCE_SEED", "NNNOTES_SERVERS_ZZ_CDN"): monkeypatch.delenv(name) - code, again, err = run(base + ["bundle", "pull", "Char/A", "Char/B"], capsys) + code, again, err = run(base + [*pull, "Char/A", "Char/B"], capsys) assert code == 0, err assert again.splitlines() == out.splitlines() * 2 @@ -207,9 +204,10 @@ def test_pull_that_downloads_names_the_missing_setting_in_one_line(tmp_path, cap catbin.write_bytes(synth.CatalogWriter().build(ENTRIES)) monkeypatch.setenv("NNNOTES_SERVERS_ZZ_CDN", cdn_tree(tmp_path / "cdn")) code, out, err = run( - ["--catalog", str(catbin), "--cache", str(tmp_path / "cache"), "--region", "zz", "bundle", "pull", "Char/A"], + ["--catalog", str(catbin), "--depot", str(tmp_path / "depot"), "--region", "zz", "bundle", "pull", "Char/A"] + + ["-o", str(tmp_path / "out")], capsys, ) assert code == 2 and out == "" and len(err.strip().splitlines()) == 1 - assert err.startswith("nnnotes: a_01.bundle is not in the cache: setting bundle.key") + assert err.startswith("nnnotes: a_01.bundle is not downloaded: setting bundle.key") assert "NNNOTES_BUNDLE_KEY" in err diff --git a/tests/source/test_catalog_versions.py b/tests/source/test_catalog_versions.py index aebfcc4..e490b82 100644 --- a/tests/source/test_catalog_versions.py +++ b/tests/source/test_catalog_versions.py @@ -33,19 +33,19 @@ def config(tmp_path, *, api=True, version=None, region="tw"): "servers": { region: {"cdn": "https://cdn.example/prod/" + region, **({"api": "https://api.example"} if api else {})} }, - "paths": {"cache": str(tmp_path / "cache")}, + "paths": {"depot": str(tmp_path / "depot")}, }, environ={}, ) def test_auto_discovery_ignores_stale_main_and_advances_resource_version(tmp_path, monkeypatch): - from nnnotes.site.webmodel import catalog_models + from nnnotes.content.live2d import LIVE2D_PREFIX, model_keys, select_models cfg = config(tmp_path) - cache = cfg.require_path("paths", "cache") - cache.mkdir() - catalog.Catalog.cache_file("en", cache).write_bytes(binary("Old")) + downloads = Session(cfg).downloads() + downloads.mkdir(parents=True) + (downloads / catalog.Catalog.file_name("en")).write_bytes(binary("Old")) publication = gameapi.MasterVersion("master-one", "1.0.0.201") seen = [] @@ -61,7 +61,7 @@ def download(url): monkeypatch.setattr(Session, "master_version", version) monkeypatch.setattr(catalog, "download", download) first = Session(cfg).catalog(bundles=False) - assert first.has(EPISODE) and catalog_models(first) == {"new": MODEL} + assert first.has(EPISODE) and select_models(None, model_keys(first.keys(LIVE2D_PREFIX))) == {"new": MODEL} assert first.resource_version == "1.0.0.201" assert deckdata.catalog_info(first)["resourceVersion"] == "1.0.0.201" assert seen[0] == "version" and len(seen) == 2 @@ -71,20 +71,20 @@ def download(url): publication = gameapi.MasterVersion("master-two", "1.0.0.202") second = Session(cfg).catalog(bundles=False) assert second.has("master-two") and not second.has("master-one") - assert first.cache_dir != second.cache_dir + assert first.download_dir != second.download_dir assert all("catalog_main" not in request for request in seen) -def test_explicit_version_cache_is_offline_and_cdn_scoped(tmp_path, monkeypatch): +def test_explicit_version_download_is_offline_and_cdn_scoped(tmp_path, monkeypatch): cfg = config(tmp_path, version="1.0.0.201") monkeypatch.setattr(Session, "master_version", lambda *args: pytest.fail("pin called Version")) monkeypatch.setattr(catalog, "download", lambda url: binary("First")) first = Session(cfg).catalog(bundles=False) - monkeypatch.setattr(catalog, "download", lambda url: pytest.fail("cached pin downloaded")) + monkeypatch.setattr(catalog, "download", lambda url: pytest.fail("downloaded pin downloaded")) assert Session(cfg).catalog(bundles=False).has("First") monkeypatch.setattr(catalog, "download", lambda url: binary("Other region")) other = Session(config(tmp_path, version="1.0.0.201", region="en")).catalog(bundles=False) - assert other.has("Other region") and first.cache_dir != other.cache_dir + assert other.has("Other region") and first.download_dir != other.download_dir def test_explicit_catalog_file_stays_offline(tmp_path, monkeypatch): @@ -94,7 +94,7 @@ def test_explicit_catalog_file_stays_offline(tmp_path, monkeypatch): { "catalog": {"region": "tw", "version": "1.0.0.201"}, "servers": {"tw": {"api": "https://api.example", "catalog": str(path)}}, - "paths": {"cache": str(tmp_path / "cache")}, + "paths": {"depot": str(tmp_path / "depot")}, }, environ={}, ) @@ -104,9 +104,9 @@ def test_explicit_catalog_file_stays_offline(tmp_path, monkeypatch): def test_missing_versioned_catalog_does_not_fall_back_to_main(tmp_path, monkeypatch): cfg = config(tmp_path, version="1.0.0.201") - cache = cfg.require_path("paths", "cache") - cache.mkdir() - catalog.Catalog.cache_file("en", cache).write_bytes(binary("Old")) + downloads = Session(cfg).downloads() + downloads.mkdir(parents=True) + (downloads / catalog.Catalog.file_name("en")).write_bytes(binary("Old")) urls = [] def missing(url): @@ -117,7 +117,7 @@ def missing(url): with pytest.raises(urllib.error.HTTPError): Session(cfg).catalog(bundles=False) assert len(urls) == 1 and "catalog_1.0.0.201_en.bin" in urls[0] - assert not list((cache / "international").rglob("*.bin")) + assert not list((downloads / "international").rglob("*.bin")) def test_failed_discovery_does_not_claim_main_is_current(tmp_path, monkeypatch): @@ -156,15 +156,16 @@ def test_build_workers_inherit_the_version_pin(tmp_path, monkeypatch): monkeypatch.setenv("NNNOTES_SERVERS_TW_CATALOG_VERSION", "1.0.0.201") monkeypatch.setenv("NNNOTES_SERVERS_TW_CDN", "https://cdn.example/prod/tw") monkeypatch.setenv("NNNOTES_SERVERS_TW_API", "https://unavailable.example") - monkeypatch.setenv("NNNOTES_PATHS_CACHE", str(tmp_path / "cache")) + monkeypatch.setenv("NNNOTES_PATHS_DEPOT", str(tmp_path / "depot")) monkeypatch.setattr(catalog, "download", lambda url: binary(MODEL, EPISODE)) assert Session(Config.load()).catalog(bundles=False).has(MODEL) command = [ sys.executable, "-c", "from nnnotes.source.config import Config; from nnnotes.source.session import Session; " - "from nnnotes.site.webmodel import catalog_models; c=Session(Config.load()).catalog(bundles=False); " - "assert 'new' in catalog_models(c); print(c.resource_version)", + "from nnnotes.content.live2d import LIVE2D_PREFIX, model_keys, select_models; " + "c=Session(Config.load()).catalog(bundles=False); " + "assert 'new' in select_models(None, model_keys(c.keys(LIVE2D_PREFIX))); print(c.resource_version)", ] child = subprocess.run(command, env=os.environ.copy(), text=True, capture_output=True, check=True) assert child.stdout.strip() == "1.0.0.201" @@ -175,7 +176,7 @@ def test_invalid_version_cannot_be_used_as_a_path(tmp_path, version): with pytest.raises(ConfigError): Session(config(tmp_path, version=version)).catalog_version() with pytest.raises(ValueError): - catalog.Catalog.cache_file("en", tmp_path, version=version) + catalog.Catalog.file_name("en", version=version) with pytest.raises(ValueError): configfile.validate(configfile.resolve("catalog.version")[0], version) @@ -187,11 +188,11 @@ def test_empty_api_version_fails_before_download(tmp_path, monkeypatch): Session(config(tmp_path)).catalog(bundles=False) -def test_bad_download_is_not_installed_in_cache(tmp_path, monkeypatch): +def test_bad_download_is_not_installed(tmp_path, monkeypatch): monkeypatch.setattr(catalog, "download", lambda url: b"not a catalog") with pytest.raises(ValueError): Session(config(tmp_path, version="1.0.0.201")).catalog(bundles=False) - assert not list((tmp_path / "cache").rglob("*.bin")) + assert not list((tmp_path / "depot").rglob("*.bin")) def test_fetch_queries_version_before_downloading_and_labels_matching_bytes(tmp_path, monkeypatch, capsys): @@ -211,9 +212,9 @@ def fetch(cdn, language, *, version): monkeypatch.setattr(Session, "master_version", observe) monkeypatch.setattr(catalogdb, "fetch", fetch) - cli.main(["--region", "tw", "--language", "en", "--cache", str(tmp_path / "cache"), "catalog", "fetch"]) + cli.main(["--region", "tw", "--language", "en", "--depot", str(tmp_path / "depot"), "catalog", "fetch"]) assert events == ["version", "1.0.0.201"] - version = catalogdb.CatalogDB(tmp_path / "cache" / "store").versions()[0] + version = catalogdb.CatalogDB(tmp_path / "depot").versions()[0] assert version["resourceVersion"] == "1.0.0.201" assert version["remote"]["sha256"] == hashlib.sha256(data).hexdigest() assert "1.0.0.201" in capsys.readouterr().out @@ -235,7 +236,7 @@ def test_browser_uses_the_same_selector(tmp_path, monkeypatch): calls = [] monkeypatch.setattr(catalog, "download", lambda url: calls.append(url) or binary(MODEL)) handler = browse.Handler.__new__(browse.Handler) - handler.server = SimpleNamespace(catalogs={}, cache=tmp_path / "cache", bundle_key=None) + handler.server = SimpleNamespace(catalogs={}, downloads=tmp_path / "depot" / "downloads", bundle_key=None) handler.catalog(browse.Region("tw", "TW", cfg.cdn("tw"), ["en"], cfg), "en") assert len(calls) == 1 and calls[0].endswith("catalog_1.0.0.201_en.bin") diff --git a/tests/source/test_configfile.py b/tests/source/test_configfile.py index b77900e..76fa57e 100644 --- a/tests/source/test_configfile.py +++ b/tests/source/test_configfile.py @@ -36,6 +36,29 @@ def leaves(table, section=""): yield f"{section}.{k}" if section else k, v +@pytest.mark.parametrize( + "name, value, ok", + [ + ("depot.remote", "s3://bucket/prefix", True), + ("depot.remote", "file:///srv/depot", True), + ("depot.remote", "s3:///prefix", False), + ("depot.remote", "https://bucket/prefix", False), + ("depot.remote", "file://host/share", False), + ("depot.endpoint", "https://storage.example", True), + ("depot.endpoint", "storage.example", False), + ("depot.downloads", "20GiB", True), + ("depot.downloads", "lots", False), + ], +) +def test_depot_settings_are_checked(name, value, ok): + setting, _ = configfile.resolve(name) + if ok: + configfile.validate(setting, value) + else: + with pytest.raises(ValueError): + configfile.validate(setting, value) + + def test_settings_are_the_documented_ones(): doc = (ROOT / "docs" / "configuration.md").read_text(encoding="utf-8") documented = set() @@ -54,7 +77,14 @@ def test_template_values_are_empty(): data = tomllib.loads(template().decode("utf-8")) assert all(v in ("", []) for _, v in leaves(data)) secrets = {f"{s.section}.{s.key}" for s in configfile.SETTINGS if s.secret} - assert secrets == {"bundle.key", "bundle.nonce_seed", "master.key", "master.iv"} + assert secrets == { + "bundle.key", + "bundle.nonce_seed", + "master.key", + "master.iv", + "depot.access_key_id", + "depot.secret_access_key", + } def test_resolve(): @@ -79,7 +109,7 @@ def test_init_with_values(tmp_path, capsys): "--set", "catalog.region=en", "--set", - "paths.cache=cache", + "paths.depot=depot", ], capsys, ) @@ -102,7 +132,7 @@ def test_init_with_values(tmp_path, capsys): "catalog_version": "", } } # the example region table renamed - assert data["paths"]["cache"] == str((tmp_path / "cache").absolute()) + assert data["paths"]["depot"] == str((tmp_path / "depot").absolute()) assert "# CDN base URL of the region" in f.read_text(encoding="utf-8") # the comments stay if os.name != "nt": assert f.stat().st_mode & 0o777 == 0o600 @@ -170,7 +200,7 @@ def ask_secret(p): got = {f"{s}.{k}": v for s, k, v in values} assert regions == ["tw", "kr"] assert got == {"bundle.key": KEY, "master.key": MKEY, "catalog.region": "tw", "catalog.language": "zh-Hant"} - assert len(secret_prompts) == 5 and all("(hidden)" in p for p in secret_prompts) + assert len(secret_prompts) == 7 and all("(hidden)" in p for p in secret_prompts) assert any(p.startswith("[servers.kr] cdn") for p in prompts) text = configfile.render(values, regions) assert set(tomllib.loads(text)["servers"]) == {"tw", "kr"} @@ -240,7 +270,7 @@ def test_check_does_not_turn_jp_language_defaults_into_flags(tmp_path, capsys): def test_check_reports_states_not_values(tmp_path, capsys, monkeypatch): (tmp_path / "nnnotes.toml").write_text( f'[bundle]\nkey = "{KEY}"\n[master]\niv = "00"\n[catalog]\nregion = "xx"\n[servers.tw]\ncdn = "ftp://h"\n' - f'[paths]\napk = "missing.apk"\ncache = "c"\n[typo]\nkey = "1"\n', + f'[paths]\napk = "missing.apk"\ndepot = "c"\n[typo]\nkey = "1"\n', encoding="utf-8", ) monkeypatch.setenv("NNNOTES_BUNDLE_NONCE_SEED", "ab") @@ -256,7 +286,7 @@ def test_check_reports_states_not_values(tmp_path, capsys, monkeypatch): assert by["master.key"]["status"] == "unset" assert by["catalog.region"]["status"] == "invalid" assert by["servers.tw.cdn"]["status"] == "invalid" - assert by["paths.apk"]["status"] == "not found" and by["paths.cache"]["status"] == "ok" + assert by["paths.apk"]["status"] == "not found" and by["paths.depot"]["status"] == "ok" assert by["typo.key"]["status"] == "unknown" and by["NNNOTES_BUNDLE_KY"]["status"] == "unknown" assert doc["problems"] == 6 code, out, _ = run(["--region", "tw", "config", "check"], capsys) diff --git a/tests/source/test_jp.py b/tests/source/test_jp.py index f318fb2..4f14d52 100644 --- a/tests/source/test_jp.py +++ b/tests/source/test_jp.py @@ -11,7 +11,6 @@ import pytest -from nnnotes import cli from nnnotes.cli.assets import CatalogFetcher from nnnotes.musicdata import deckdata from nnnotes.pipeline import catalogdb @@ -173,14 +172,14 @@ def test_authenticated_download_rotation_and_redaction(upstream): assert "Authorization" not in json.dumps(obs.source.to_dict()) -def test_truncated_download_is_not_cached(upstream, tmp_path): +def test_truncated_download_is_not_kept(upstream, tmp_path): client = jp.Client(upstream.cfg, "jp") source = client.observe().source upstream.content_length = len(upstream.body) + 5 cat = Catalog(cat_bytes(), tmp_path, source=source, client=client) with pytest.raises(gameapi.GameApiError, match="truncated"): cat.fetch_raw({"internal_id": jp.PLACEHOLDER + "audio/test.acb"}) - assert cat.cached_raw({"internal_id": jp.PLACEHOLDER + "audio/test.acb"}) is None + assert cat.downloaded_raw({"internal_id": jp.PLACEHOLDER + "audio/test.acb"}) is None def test_declared_download_limit_is_checked(upstream): @@ -246,16 +245,16 @@ def test_placeholder_rejects_unsafe_paths(path): location_kind(jp.PLACEHOLDER + path) -def test_catalog_cache_source_and_offline_replay(upstream, tmp_path, monkeypatch): +def test_catalog_download_source_and_offline_replay(upstream, tmp_path, monkeypatch): cfg = upstream.cfg - cfg._data["paths"] = {"cache": str(tmp_path / "cache")} + cfg._data["paths"] = {"depot": str(tmp_path / "depot")} cat = Session(cfg).catalog(bundles=False) - path = cat.cache_dir / "catalog_main.bin" + path = cat.download_dir / "catalog_main.bin" assert path.read_bytes() == upstream.body assert jp.read_source(path, upstream.body) == cat.source - assert cat.cache_dir != tmp_path / "cache" + assert cat.download_dir != tmp_path / "depot" / "downloads" assert cat.keys("Live/") == ["Live/MusicScore/0001/0001_00"] - record = catalogdb.CatalogDB(tmp_path / "store").add(upstream.body, source=cat.source.to_dict(), region="jp") + record = catalogdb.CatalogDB(tmp_path / "depot").add(upstream.body, source=cat.source.to_dict(), region="jp") assert upstream.credential not in json.dumps(record) cfg._data["paths"]["catalog"] = str(path) monkeypatch.setattr(jp.Client, "observe", lambda *a, **k: pytest.fail("offline read made an API call")) @@ -267,27 +266,27 @@ def test_catalog_cache_source_and_offline_replay(upstream, tmp_path, monkeypatch def test_catalogdb_source_identity_and_fetcher(upstream, tmp_path): cfg = upstream.cfg - cfg._data["paths"] = {"cache": str(tmp_path / "cache")} + cfg._data["paths"] = {"depot": str(tmp_path / "depot")} client = jp.Client(cfg, "jp") source = client.observe().source - db = catalogdb.CatalogDB(tmp_path / "store") + db = catalogdb.CatalogDB(tmp_path / "depot") old = db.add(cat_bytes(), source=source.to_dict(), region="jp") new_source = {**source.to_dict(), "hash": "c" * 32} new = db.add(cat_bytes(), source=new_source, region="jp") assert old["id"] != new["id"] and old["labels"] != new["labels"] assert old["remote"] == new["remote"] - fetcher = CatalogFetcher(tmp_path / "store", tmp_path / "cache", cfg) + fetcher = CatalogFetcher(tmp_path / "depot", cfg) cat, _ = fetcher.catalog(old["id"]) - assert cat.source == source and cat.cache_dir == source.cache_dir(tmp_path / "cache") + assert cat.source == source and cat.download_dir == source.download_dir(tmp_path / "depot" / "downloads") -def test_apk_update_with_same_asset_snapshot_does_not_reuse_embedded_cache(tmp_path): +def test_apk_update_with_same_asset_snapshot_does_not_reuse_embedded_downloads(tmp_path): from nnnotes.source.catalog import APK_AA_DIR, APK_CATALOG source = jp.Source("jp", "1.0", HASH, "https://cdn.invalid") apk = tmp_path / "base.apk" - cache = tmp_path / "cache" - db = catalogdb.CatalogDB(tmp_path / "store") + downloads = tmp_path / "depot" / "downloads" + db = catalogdb.CatalogDB(tmp_path / "depot") cfg = Config({"catalog": {"region": "jp"}, "paths": {"apk": str(apk)}}, environ={}) from nnnotes.cli.assets import Workspace @@ -298,14 +297,14 @@ def test_apk_update_with_same_asset_snapshot_does_not_reuse_embedded_cache(tmp_p ) content = b"UnityFS\0" + bytes([revision]) apk.write_bytes(zip_bytes({APK_CATALOG: local, APK_AA_DIR + "Android/local.bundle": content})) - cat = Catalog(cat_bytes(), source.cache_dir(cache), source=source, apk=apk) + cat = Catalog(cat_bytes(), source.download_dir(downloads), source=source, apk=apk) v = db.add(cat_bytes(), local, source=source.to_dict(), region="jp") workspace = Workspace.__new__(Workspace) workspace.version = v - rel = workspace._cache_rel("bundle", {"name": "local.bundle", "remote": False}, {}) + rel = workspace._download_rel("bundle", {"name": "local.bundle", "remote": False}, {}) assert cat.fetch(cat.resolve("Local")[0]).read_bytes() == content - assert (cache / rel).read_bytes() == content - fetcher = CatalogFetcher(tmp_path / "store", cache, cfg) + assert (downloads / rel).read_bytes() == content + fetcher = CatalogFetcher(tmp_path / "depot", cfg) replay, _ = fetcher.catalog(v["id"]) assert replay.fetch(replay.resolve("Local")[0]).read_bytes() == content assert len(db.versions()) == 2 @@ -329,7 +328,7 @@ def test_remote_replay_does_not_open_the_apk(upstream, tmp_path, apk_state, kind cfg = upstream.cfg cfg._data["bundle"] = {"key": "00" * 16, "nonce_seed": "11"} - cfg._data["paths"] = {"cache": str(tmp_path / "cache")} + cfg._data["paths"] = {"depot": str(tmp_path / "depot")} apk = tmp_path / "base.apk" if apk_state != "unset": cfg._data["paths"]["apk"] = str(apk) @@ -338,18 +337,18 @@ def test_remote_replay_does_not_open_the_apk(upstream, tmp_path, apk_state, kind elif apk_state == "broken": apk.write_bytes(b"not a zip") source = jp.Client(cfg, "jp").observe().source - db = catalogdb.CatalogDB(tmp_path / "store") + db = catalogdb.CatalogDB(tmp_path / "depot") record = db.add(cat_bytes(), local_catalog(), source=source.to_dict(), region="jp") entry = next(e for e in db.index(record)[kind] if e["remote"]) upstream.body = b"UnityFS\0synthetic resource" - fetcher = CatalogFetcher(tmp_path / "store", tmp_path / "cache", cfg) + fetcher = CatalogFetcher(tmp_path / "depot", cfg) assert fetcher.fetch_location(record["id"], entry["location"]).read_bytes() == upstream.body assert len(upstream.seen) == 1 @pytest.mark.parametrize("kind", ["bundles", "rawFiles"]) -@pytest.mark.parametrize("state", ["matching", "different", "no-catalog", "cached"]) -def test_local_replay_validates_only_on_cache_miss(tmp_path, kind, state): +@pytest.mark.parametrize("state", ["matching", "different", "no-catalog", "downloaded"]) +def test_local_replay_validates_only_when_not_downloaded(tmp_path, kind, state): from nnnotes.source.catalog import APK_AA_DIR, APK_CATALOG source = jp.Source("jp", "1.0", HASH, "https://cdn.invalid") @@ -359,20 +358,20 @@ def test_local_replay_validates_only_on_cache_miss(tmp_path, kind, state): members[APK_CATALOG] = local_catalog(2 if state == "different" else 1) apk.write_bytes(zip_bytes(members)) cfg = Config({"catalog": {"region": "jp"}, "paths": {"apk": str(apk)}}, environ={}) - db = catalogdb.CatalogDB(tmp_path / "store") + db = catalogdb.CatalogDB(tmp_path / "depot") record = db.add(cat_bytes(), local_catalog(), source=source.to_dict(), region="jp") entry = next(e for e in db.index(record)[kind] if not e["remote"]) - fetcher = CatalogFetcher(tmp_path / "store", tmp_path / "cache", cfg) + fetcher = CatalogFetcher(tmp_path / "depot", cfg) if state in ("different", "no-catalog"): with pytest.raises(ConfigError, match="APK set it was imported with"): fetcher.fetch_location(record["id"], entry["location"]) return expected = members[APK_AA_DIR + ("Android/local.bundle" if kind == "bundles" else "Android/local.acb")] assert fetcher.fetch_location(record["id"], entry["location"]).read_bytes() == expected - if state == "cached": + if state == "downloaded": apk.unlink() cfg._data["paths"].pop("apk") - replay = CatalogFetcher(tmp_path / "store", tmp_path / "cache", cfg) + replay = CatalogFetcher(tmp_path / "depot", cfg) assert replay.fetch_location(record["id"], entry["location"]).read_bytes() == expected @@ -431,40 +430,6 @@ def test_per_region_apk_and_client_settings(tmp_path): assert configfile.resolve("servers.jp.client_version")[0].kind == "string" -@pytest.mark.parametrize("kind", ["--all-live2d", "--all-stories"]) -@pytest.mark.parametrize("region", ["jp", "tw"]) -def test_web_preflight_uses_the_selected_regions_apk(tmp_path, monkeypatch, kind, region): - from nnnotes.site import sitefiles, storysite, webmodel - from nnnotes.unity import tmpfont - - conf = tmp_path / "nnnotes.toml" - default = "jp" if region == "tw" else "tw" - conf.write_text( - f'[catalog]\nregion="{default}"\nlanguage="ja"\n' - f'[servers.{default}]\ncdn="https://default.invalid"\n' - f'[servers.{region}]\ncdn="https://target.invalid"\napk="target.apk"\n' - '[paths]\nplayer="player"\n', - encoding="utf-8", - ) - (tmp_path / "target.apk").write_bytes(b"synthetic") - monkeypatch.setattr(tmpfont, "require_extra", lambda *a, **kw: None) - monkeypatch.setattr(sitefiles, "check_player", lambda *a, **kw: tmp_path / "player") - monkeypatch.setattr(Session, "catalog", lambda *a, **kw: object()) - monkeypatch.setattr(webmodel, "catalog_models", lambda *a, **kw: {}) - seen = [] - - def build(out, selection, cfg, *args, **kwargs): - seen.append((cfg.region(), cfg.path("paths", "apk"))) - return {} - - monkeypatch.setattr(webmodel, "build", build) - monkeypatch.setattr(storysite, "build", build) - cli.main( - ["--config", str(conf), "--json", "site", "build", "-o", str(tmp_path / "site"), kind, "--site-region", region] - ) - assert seen == [(region, tmp_path / "target.apk")] - - def test_datapack_is_loaded_in_the_boot_environment(tmp_path, monkeypatch): from nnnotes.unity import player @@ -522,16 +487,16 @@ def test_jp_master_rejects_missing_or_malformed_file_lists(tmp_path, files): assert not (tmp_path / "MasterManifest.json").exists() -def test_export_cache_locator_uses_source_namespace(tmp_path): +def test_export_download_locator_uses_source_namespace(tmp_path): from nnnotes.cli.assets import Workspace source = jp.Source("jp", "1.0.0.300", HASH, "https://cdn.invalid") workspace = Workspace.__new__(Workspace) workspace.version = {"source": source.to_dict()} - rel = workspace._cache_rel("bundle", {"name": "same.bundle"}, {}) - assert tmp_path / rel == source.cache_dir(tmp_path) / "bundles/same.bundle" - rel = workspace._cache_rel("raw", {}, {"internalId": jp.PLACEHOLDER + "audio/sample.acb"}) - assert tmp_path / rel == source.cache_dir(tmp_path) / "raw/audio/sample.acb" + rel = workspace._download_rel("bundle", {"name": "same.bundle"}, {}) + assert tmp_path / rel == source.download_dir(tmp_path) / "bundles/same.bundle" + rel = workspace._download_rel("raw", {}, {"internalId": jp.PLACEHOLDER + "audio/sample.acb"}) + assert tmp_path / rel == source.download_dir(tmp_path) / "raw/audio/sample.acb" def test_music_data_preserves_asset_hash(tmp_path): diff --git a/tests/test_api.py b/tests/test_api.py index 98905d3..dfdb83b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -13,7 +13,6 @@ "nnnotes.musicdata", "nnnotes.musicdata.deckdata", "nnnotes.schemas", - "nnnotes.site.webmodel", "nnnotes.source.addressables", "nnnotes.source.catalog", "nnnotes.source.config", diff --git a/tests/pipeline/test_atoms_mesh.py b/tests/unity/test_atoms_mesh.py similarity index 92% rename from tests/pipeline/test_atoms_mesh.py rename to tests/unity/test_atoms_mesh.py index 2e89bd2..e6e5070 100644 --- a/tests/pipeline/test_atoms_mesh.py +++ b/tests/unity/test_atoms_mesh.py @@ -8,10 +8,10 @@ import pytest from UnityPy.helpers.MeshHelper import MeshHandler -from nnnotes.pipeline.atoms import ATOMS, Unsupported, gltf -from nnnotes.pipeline.atoms.mesh import MeshInput, arrays -from nnnotes.pipeline.atoms.reader import UnityBundle, handler_input, mesh_input_from_typetree from nnnotes.unity import reader as unity +from nnnotes.unity.atoms import ATOMS, Unsupported, gltf +from nnnotes.unity.atoms.mesh import MeshInput, arrays +from nnnotes.unity.atoms.reader import UnityBundle, handler_input, mesh_input_from_typetree from tests.helpers.fakeunity import ( BLENDINDICES, BLENDWEIGHT, @@ -224,6 +224,26 @@ def test_skin_with_two_weights_per_vertex(): assert ex["bindPoses"][1] == (np.eye(4) * 2).tolist() +def test_arrays_document_of_a_skinned_line_mesh_and_an_empty_mesh(): + from nnnotes.unity.convert.files import arrays_document + + count = 4 + ch = channels(count, skin="x2", color=False) + tt = mesh_tt("m", count, ch, [0, 1, 1, 2], [submesh(0, 4, 3)], bind_poses=[matrix(np.eye(4))]) + doc = arrays_document(mesh_of(tt)) + assert doc["positions"] == ch[POSITION][2].astype(np.float32).astype(float).tolist() + assert doc["submeshes"] == [{"topology": "lines", "indices": [0, 1, 1, 2]}] + assert doc["colors"] is None and doc["uv1"] is None and doc["uv0"] is not None + assert len(doc["tangents"][0]) == 4 and len(doc["normals"][0]) == 3 + assert [row[:2] for row in doc["joints"]] == ch[BLENDINDICES][2].tolist() + assert [row[2:] for row in doc["joints"]] == [[0, 0]] * count + assert [row[:2] for row in doc["weights"]] == ch[BLENDWEIGHT][2].astype(np.float32).astype(float).tolist() + assert [row[2:] for row in doc["weights"]] == [[0.0, 0.0]] * count + empty = arrays_document(mesh_of(mesh_tt("e", 0, {}, [], []))) + assert empty["positions"] == [] and empty["submeshes"] == [] + assert all(empty[k] is None for k in ("normals", "tangents", "colors", "uv0", "joints", "weights")) + + def test_indices_only_skin_is_one_bone_per_vertex(): count = 4 ch = channels(count, skin="x1") diff --git a/tests/pipeline/test_atoms_sprite.py b/tests/unity/test_atoms_sprite.py similarity index 97% rename from tests/pipeline/test_atoms_sprite.py rename to tests/unity/test_atoms_sprite.py index 7adc803..e06f8e9 100644 --- a/tests/pipeline/test_atoms_sprite.py +++ b/tests/unity/test_atoms_sprite.py @@ -7,10 +7,10 @@ import pytest from UnityPy.export.SpriteHelper import get_image_from_sprite -from nnnotes.pipeline.atoms import ATOMS, Unsupported, sprite -from nnnotes.pipeline.atoms.mesh import arrays -from nnnotes.pipeline.atoms.reader import mesh_input_from_typetree -from nnnotes.pipeline.atoms.texture import TextureInput, decode +from nnnotes.unity.atoms import ATOMS, Unsupported, sprite +from nnnotes.unity.atoms.mesh import arrays +from nnnotes.unity.atoms.reader import mesh_input_from_typetree +from nnnotes.unity.atoms.texture import TextureInput, decode from tests.helpers.fakeunity import ( ANDROID, VERSION, diff --git a/tests/pipeline/test_atoms_texture.py b/tests/unity/test_atoms_texture.py similarity index 90% rename from tests/pipeline/test_atoms_texture.py rename to tests/unity/test_atoms_texture.py index 6d4f6dc..28b9f79 100644 --- a/tests/pipeline/test_atoms_texture.py +++ b/tests/unity/test_atoms_texture.py @@ -1,6 +1,4 @@ -"""texture.decode, png.encode and astc.container on synthetic textures (decode = UnityPy's parse_image_data; PNG bytes -= export.texture_png's), the reader's objects, containers and texture inputs through a fake environment, and the -atom registry.""" +"""Texture decoding, lossless PNG output and container inputs over synthetic textures.""" import io import re @@ -13,10 +11,9 @@ from PIL import Image from UnityPy.export.Texture2DConverter import parse_image_data -from nnnotes.pipeline.atoms import ATOMS, Impl, Unsupported, astc, env_name, ids, impl_id, png, resolve -from nnnotes.pipeline.atoms.reader import ObjectInfo, UnityBundle -from nnnotes.pipeline.atoms.texture import ASTC_BLOCKS, TextureInput, decode, is_hdr_astc -from nnnotes.unity import export +from nnnotes.unity.atoms import ATOMS, Impl, Unsupported, astc, env_name, ids, impl_id, png, resolve +from nnnotes.unity.atoms.reader import ObjectInfo, UnityBundle +from nnnotes.unity.atoms.texture import ASTC_BLOCKS, TextureInput, decode, is_hdr_astc from tests.helpers.fakeunity import ANDROID, VERSION, FakeEnvironment, FakeTexture2D, rgba_bytes RGBA32, RGB24, ALPHA8, ARGB4444, YUY2 = 4, 3, 1, 2, 21 @@ -69,9 +66,12 @@ def test_decode_is_parse_image_data(fmt): @pytest.mark.parametrize("fmt", FORMATS) -def test_png_bytes_equal_export_texture_png(fmt): +def test_png_bytes_match_the_decoded_image(fmt): tex = fake(fmt, seed=fmt) - assert png.encode(decode(inp(tex))) == export.texture_png(tex) + image = parse_image_data(tex.get_image_data(), tex.m_Width, tex.m_Height, fmt, VERSION, ANDROID, [], True) + expected = io.BytesIO() + image.save(expected, format="PNG") + assert png.encode(decode(inp(tex))) == expected.getvalue() @pytest.mark.parametrize("fmt", [RGBA32, ASTC_6x6]) @@ -81,11 +81,6 @@ def test_further_mip_levels_do_not_change_the_image(fmt): assert decode(inp(mips)).tobytes() == decode(inp(base)).tobytes() -def test_decode_input_key_is_export_decode_inputs(): - tex = fake(RGBA32) - assert inp(tex).key() == export._decode_inputs(tex) - - @pytest.mark.parametrize("fmt", [f for f in range(66, 72)]) def test_hdr_astc_is_refused(fmt): assert is_hdr_astc(fmt) @@ -204,7 +199,7 @@ def test_reader_texture_inputs(): env, view, inline, streamed_data = bundle() objs = {o.path_id: o for o in view.objects()} ti = view.texture_input(objs[-5]) - assert ti.key() == export._decode_inputs(inline) + assert ti.key() == inp(inline).key() assert view.texture_input(objs[7]).data == streamed_data and env.lookups == ["CAB-aa.resS"] env.lookups.clear() empty = view.texture_input(objs[9]) @@ -255,7 +250,7 @@ def test_every_atom_has_one_reference_with_versioned_id(): def test_evaluation_override(monkeypatch): assert env_name("png.encode") == "NNNOTES_ATOM_PNG_ENCODE" - monkeypatch.setenv("NNNOTES_ATOM_PNG_ENCODE", "tests.pipeline.test_atoms_texture:REPLACEMENT") + monkeypatch.setenv("NNNOTES_ATOM_PNG_ENCODE", "tests.unity.test_atoms_texture:REPLACEMENT") assert resolve("png.encode") is REPLACEMENT and ATOMS["png.encode"].fn(None) == b"replaced" assert ids("png.encode") == {"png.encode": "pillow-test/9"} # keys change with the override monkeypatch.setenv("NNNOTES_ATOM_PNG_ENCODE", "no-colon") @@ -268,17 +263,17 @@ def test_evaluation_override(monkeypatch): def test_override_with_the_reference_id_is_refused(monkeypatch): - import tests.pipeline.test_atoms_texture as me + import tests.unity.test_atoms_texture as me monkeypatch.setattr(me, "SAME", Impl(ATOMS["png.encode"].id, png.encode, png.cost), raising=False) - monkeypatch.setenv("NNNOTES_ATOM_PNG_ENCODE", "tests.pipeline.test_atoms_texture:SAME") + monkeypatch.setenv("NNNOTES_ATOM_PNG_ENCODE", "tests.unity.test_atoms_texture:SAME") with pytest.raises(ValueError, match="reference's id"): resolve("png.encode") def test_listing_the_registry_loads_no_image_or_unity_library(): code = ( - "import sys, nnnotes.pipeline.atoms as a; a.ids(*a.ATOMS); " + "import sys, nnnotes.unity.atoms as a; a.ids(*a.ATOMS); " "print(sorted(m for m in ('UnityPy', 'PIL') if m in sys.modules))" ) out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdout @@ -296,7 +291,7 @@ def test_every_atom_and_reason_code_is_documented(): assert documented == set(ATOMS) codes = set(re.findall(r"^\| `([a-zA-Z_.]+)` \|", doc.split("## Reason codes", 1)[1], re.M)) emitted = set() - for src in (root / "src" / "nnnotes" / "pipeline" / "atoms").glob("*.py"): + for src in (root / "src" / "nnnotes" / "unity" / "atoms").glob("*.py"): emitted |= set( re.findall( r"\"((?:empty|unsupported|partial|generic|no_data)\.[a-zA-Z_.]+)\"", src.read_text(encoding="utf-8") diff --git a/tests/unity/test_bindings.py b/tests/unity/test_bindings.py new file mode 100644 index 0000000..651e02e --- /dev/null +++ b/tests/unity/test_bindings.py @@ -0,0 +1,149 @@ +"""Clip bindings named in a synthetic prefab hierarchy (nnnotes.unity.bindings).""" + +import pytest + +from nnnotes.unity import bindings +from nnnotes.unity.bindings import crc + +PARAM = "Live2D.Cubism|Live2D.Cubism.Core|CubismParameter" +PART = "Live2D.Cubism|Live2D.Cubism.Core|CubismPart" +MAT = {"$ref": {"file": "CAB-m", "pathId": "7", "class": "Material"}} + + +def mono(script, **fields): + return {"type": "MonoBehaviour", "pathId": "1", "script": script, "fields": fields} + + +def node(path, *components): + return {"path": path, "name": path.rsplit("/", 1)[-1], "components": list(components)} + + +def prefab(): + return { + "name": "model", + "nodes": [ + node("model", mono("Live2D.Cubism|Live2D.Cubism.Rendering|CubismRenderController", Opacity=1.0)), + node("model/Parameters"), + node("model/Parameters/ParamAngleX", mono(PARAM, Value=0.0, MinimumValue=-30.0)), + node("model/Parameters/ParamAngleY", mono(PARAM, Value=0.0, MinimumValue=-30.0)), + node("model/Parts"), + node("model/Parts/PartArm", mono(PART, Opacity=1.0)), + node("model/Drawables"), + node( + "model/Drawables/ArtMesh0", + {"type": "MeshRenderer", "pathId": "2", "fields": {"m_Enabled": 1, "m_Materials": [MAT, None]}}, + ), + node("model/Twin"), + node("model/Twin"), + node("other"), + ], + } + + +def binding(path, attr, type_id=114, script=None, **kw): + b = {"pathCrc": crc(path), "attributeCrc": crc(attr) if isinstance(attr, str) else attr} + b.update(typeID=type_id, customType=0, pptr=False, int=False, serializeReference=False) + if script is not None: + b.update(script={"$ref": {"file": "CAB-s", "pathId": "3"}}, scriptClass=script) + b.update(kw) + return b + + +def test_paths_are_relative_to_the_animators_game_object(): + out = bindings.resolve( + [ + binding("Parameters/ParamAngleX", "Value", script=PARAM), + binding("Parts/PartArm", "Opacity", script=PART), + binding("", "Opacity", script="Live2D.Cubism|Live2D.Cubism.Rendering|CubismRenderController"), + binding("Parameters/ParamGone", "Value", script=PARAM), + ], + prefab(), + "model", + ) + assert out == [ + {"path": "Parameters/ParamAngleX", "attribute": "Value"}, + {"path": "Parts/PartArm", "attribute": "Opacity"}, + {"path": "", "attribute": "Opacity"}, + {"path": None, "attribute": "Value"}, # not below the Animator: a field of the class in the hierarchy + ] + below = bindings.resolve([binding("ParamAngleY", "Value", script=PARAM)], prefab(), "model/Parameters") + assert below == [{"path": "ParamAngleY", "attribute": "Value"}] + + +def test_a_path_two_transforms_share_names_them_both(): + (r,) = bindings.resolve([binding("Twin", "m_IsActive", type_id=1)], prefab(), "model") + assert r == {"path": "Twin", "attribute": "m_IsActive"} # an engine property + + +def test_the_bound_component_is_the_bindings_script(): + other = "Game|Game|Other" + (r,) = bindings.resolve([binding("Parameters/ParamAngleX", "MinimumValue", script=other)], prefab(), "model") + assert r == {"path": "Parameters/ParamAngleX", "attribute": None} # no Other there, nor anywhere + + +def test_a_transform_bindings_attribute_is_the_clips(): + b = binding("Parts", 2, type_id=4, attribute="m_LocalRotation", curves=4) + assert bindings.resolve([b], prefab(), "model") == [{"path": "Parts", "attribute": "m_LocalRotation"}] + + +def test_a_renderer_material_binding_names_a_property_of_its_materials(): + tint = crc("_TintColor") & 0x0FFFFFFF + docs = {"7": {"floats": {"_Cutoff": 0.5}, "colors": {"_TintColor": {"r": 1, "g": 1, "b": 1, "a": 1}}}} + + def material(ref): + return docs.get(ref["$ref"]["pathId"]) + + out = bindings.resolve( + [ + binding( + "Drawables/ArtMesh0", tint | 3 << 28 | 1 << 30, type_id=23, customType=22, **{"class": "MeshRenderer"} + ), + binding("Drawables/ArtMesh0", tint | 1 << 28, type_id=23, customType=22), + binding("Drawables/ArtMesh0", crc("_Cutoff") & 0x0FFFFFFF, type_id=23, customType=22), + binding("Drawables/ArtMesh0", crc("_Other") & 0x0FFFFFFF, type_id=23, customType=22), + ], + prefab(), + "model", + material, + ) + assert [r["attribute"] for r in out] == ["material._TintColor.a", "material._TintColor.y", "material._Cutoff", None] + assert bindings.resolve([binding("Drawables/ArtMesh0", tint, type_id=23, customType=22)], prefab(), "model") == [ + {"path": "Drawables/ArtMesh0", "attribute": None} # its materials unknown + ] + + +def test_a_field_of_the_bound_renderer(): + (r,) = bindings.resolve([binding("Drawables/ArtMesh0", "m_Enabled", type_id=23)], prefab(), "model") + assert r == {"path": "Drawables/ArtMesh0", "attribute": "m_Enabled"} + + +def test_an_ambiguous_path_lists_its_candidates(monkeypatch): + monkeypatch.setattr(bindings, "crc", lambda s: 1 if s in ("Parts", "Drawables") else 0x7FFFFFFF) + (r,) = bindings.resolve([{"pathCrc": 1, "attributeCrc": 5, "typeID": 1}], prefab(), "model") + assert r == {"path": None, "attribute": None, "pathCandidates": ["Drawables", "Parts"]} + + +def test_the_animators_game_object_is_in_the_hierarchy(): + with pytest.raises(KeyError, match="not in the hierarchy"): + bindings.resolve([], prefab(), "nope") + + +def test_material_attribute_layout(): + kinds = {"_Color": "vector", "_Alpha": "float"} + c = crc("_Color") & 0x0FFFFFFF + assert bindings.material_attribute(c | 0 << 28 | 1 << 30, kinds) == "material._Color.r" + assert bindings.material_attribute(c | 2 << 28, kinds) == "material._Color.z" + assert bindings.material_attribute(crc("_Alpha") & 0x0FFFFFFF, kinds) == "material._Alpha" + assert bindings.material_attribute(crc("_Alpha") & 0x0FFFFFFF | 1 << 30, kinds) == "material._Alpha.r" + assert bindings.material_prop_names(["_Extra"])[:3] == [ + "material._AddColor", + "material._AddColor.r", + "material._AddColor.g", + ] + assert "material._Extra.w" in bindings.material_prop_names(["_Extra"]) + assert bindings.flat_names({"m_Color": {"r": 1, "g": 0}, "list": [1]}) == [ + "m_Color", + "m_Color.r", + "m_Color.g", + "list", + ] diff --git a/tests/unity/test_container.py b/tests/unity/test_container.py new file mode 100644 index 0000000..d6f9b67 --- /dev/null +++ b/tests/unity/test_container.py @@ -0,0 +1,66 @@ +"""Container-path lookup and jacket decoding over synthetic bundle objects.""" + +from types import SimpleNamespace + +import pytest +from PIL import Image + +from nnnotes.musicdata import MusicDataError, catalog_jacket +from nnnotes.unity import reader + + +def environment(monkeypatch, asset): + pointer = {"m_FileID": 1, "m_PathID": 7} + owner = SimpleNamespace( + type=SimpleNamespace(name="AssetBundle"), + read_typetree=lambda: {"m_Container": [("Assets/Jacket.PNG", {"asset": pointer})]}, + ) + + def dereference(source, ref): + assert source is owner and ref == pointer + return asset + + monkeypatch.setattr(reader, "deref", dereference) + return SimpleNamespace(objects=[SimpleNamespace(type=SimpleNamespace(name="Texture2D")), owner]) + + +def test_container_matches_full_paths_and_resolves_external_references(monkeypatch): + asset = object() + env = environment(monkeypatch, asset) + assert reader.container_asset(env, "assets/jacket.png") is asset + with pytest.raises(KeyError, match="not in any AssetBundle container"): + reader.container_asset(env, "jacket.png") + with pytest.raises(KeyError, match="null reference"): + reader.container_asset(environment(monkeypatch, None), "assets/jacket.png") + + +def test_jacket_reads_and_resizes_the_named_texture(monkeypatch): + import io + + asset = SimpleNamespace( + type=SimpleNamespace(name="Texture2D"), + read=lambda: SimpleNamespace(image=Image.new("RGBA", (20, 8), (64, 128, 192, 255))), + ) + env = environment(monkeypatch, asset) + requested = [] + + class Catalog: + def fetch_key(self, key): + requested.append(key) + return ["bundle"] + + def entry(self, key): + return {"internal_id": "Assets/Jacket.PNG"} + + def load(paths): + assert paths == ["bundle"] + return env + + monkeypatch.setattr(reader, "load_closure", load) + get = catalog_jacket(Catalog(), size=10) + image = Image.open(io.BytesIO(get("sample"))) + assert image.format == "WEBP" and image.size == (10, 4) + assert requested == ["Image/Jacket/sample"] + asset.type.name = "Sprite" + with pytest.raises(MusicDataError, match="is not a Texture2D"): + get("sample") diff --git a/tests/unity/test_fontsettings.py b/tests/unity/test_fontsettings.py index 2a10e0e..42da8ff 100644 --- a/tests/unity/test_fontsettings.py +++ b/tests/unity/test_fontsettings.py @@ -4,7 +4,7 @@ import pytest -from nnnotes.unity import export, fontsettings +from nnnotes.unity import fontsettings from nnnotes.unity.fontsettings import END_OF_TEXT, SPACE FACE = {"m_PointSize": 40.0, "m_Scale": 1.0} @@ -147,16 +147,6 @@ def test_write_one_entry_per_line(tmp_path): assert sum(ln.startswith('"7":') or ln.startswith('"8":') for ln in lines) == 2 -def test_font_stub_resolves_from_the_objects(): - """A reference into a font bundle left out of the closure: a font asset is named, a material is its record.""" - s = fontsettings.FontSettings(table(**{"A SDF": font("A", material="A SDF Material")})) - ex = export.Exporter.__new__(export.Exporter) - ex.font_stubs = s - asset = export.FontStub(s.objects[7], 7) - assert ex.font_stub(asset) == {"asset": fontsettings.FONT_CLASS, "name": "A SDF"} - assert ex.font_stub(export.FontStub(s.objects[8], 8))["floats"] == {"_GradientScale": 10.0} - - def test_builtin_table_is_complete(): """Every fallback, default material and object the shipped table names is in it.""" s = fontsettings.builtin() diff --git a/tests/unity/test_mesh_texture_guards.py b/tests/unity/test_mesh_texture_guards.py index 2a42cc8..8d7e383 100644 --- a/tests/unity/test_mesh_texture_guards.py +++ b/tests/unity/test_mesh_texture_guards.py @@ -6,7 +6,6 @@ import pytest from UnityPy.enums import TextureFormat -from nnnotes.unity import export from nnnotes.unity import reader as unity @@ -106,8 +105,6 @@ def test_hdr_astc_textures_are_refused_before_decoding(fmt): tex = FakeTexture(int(fmt)) # UnityPy may give the format as a plain int with pytest.raises(unity.UnsupportedTexture, match=rf"'number1_glow': {fmt.name} \(HDR ASTC\)"): unity.texture_image(tex) - with pytest.raises(unity.UnsupportedTexture): - export.texture_png(tex) assert tex.decoded == 0 diff --git a/tests/pipeline/test_sniff.py b/tests/unity/test_sniff.py similarity index 97% rename from tests/pipeline/test_sniff.py rename to tests/unity/test_sniff.py index bc3df9b..1c9d70b 100644 --- a/tests/pipeline/test_sniff.py +++ b/tests/unity/test_sniff.py @@ -9,8 +9,8 @@ import pytest from nnnotes.pipeline import contract -from nnnotes.pipeline.atoms import ATOMS -from nnnotes.pipeline.atoms.sniff import MAGIC, Sniffed, sniff, spine_binary_version +from nnnotes.unity.atoms import ATOMS +from nnnotes.unity.atoms.sniff import MAGIC, Sniffed, sniff, spine_binary_version def sfnt(tag: bytes, tables: int = 3) -> bytes: diff --git a/tests/unity/test_speed_exact.py b/tests/unity/test_speed_exact.py index 24fa7c5..1e89b98 100644 --- a/tests/unity/test_speed_exact.py +++ b/tests/unity/test_speed_exact.py @@ -20,18 +20,17 @@ from nnnotes.core import cache, jsonio from nnnotes.formats import cri, encoders, shader -from nnnotes.unity import export from nnnotes.unity import reader as unity -from nnnotes.unity.export import Exporter, TexelPacker +from nnnotes.unity import texels as export from nnnotes.unity.reader import SceneGraph @pytest.fixture(autouse=True) def fresh_cache(): - cache.configure(enabled=True, memory_mb=cache.DEFAULT_MEMORY_MB, directory="") + cache.configure(enabled=True, memory_mb=cache.DEFAULT_MEMORY_MB) unity.clear_closures() yield - cache.configure(enabled=True, memory_mb=cache.DEFAULT_MEMORY_MB, directory="", closures=4, closure_mb=512) + cache.configure(enabled=True, memory_mb=cache.DEFAULT_MEMORY_MB, closures=4, closure_mb=512) unity.clear_closures() @@ -217,26 +216,6 @@ def ref_value(ex, owner, v): return v -def test_value_fast_path(tmp_path): - ex = Exporter(None, tmp_path) - ex.ref = lambda owner, p: {"ref": p["m_PathID"]} - rng = random.Random(5) - for _ in range(200): - v = [rng.random() for _ in range(rng.randrange(5))] - doc = { - "a": v, - "b": [1, "x", None, True], - "c": [{"m_FileID": 0, "m_PathID": 3}, 2.0], - "d": [b"\x01", 1], - "e": [[1, 2], [3]], - "f": [], - "g": {"h": [{"m_FileID": 1, "m_PathID": 0}]}, - } - out = ex.value(None, doc) - assert out == ref_value(ex, None, doc) - assert out["a"] is not v and out["f"] == [] # new lists, as before - - # ---------------------------------------------------------------- textures class FakeTex: decodes = 0 @@ -283,33 +262,6 @@ def png_of(img, **kw): return b.getvalue() -def test_texture_png_cached_and_equal(): - t = FakeTex(b"abcdefgh" * 10, 16, 8) - FakeTex.decodes = 0 - want = png_of(t.image) - assert export.texture_png(t) == want - assert export.texture_png(FakeTex(b"abcdefgh" * 10, 16, 8)) == want # same inputs: no decode - assert FakeTex.decodes == 2 - assert export.texture_png(FakeTex(b"abcdefgh" * 10, 8, 16)) != want # other size: decoded - cache.configure(enabled=False) - assert export.texture_png(t) == want - - -def test_exporter_texture_files_immediate_and_deferred(tmp_path): - objs = [tex_obj(FakeTex(bytes([i]) * 64, 8, 4), name=f"t{i}", pid=i) for i in range(4)] - a = Exporter(None, tmp_path / "a") - recs_a = [a.texture(o) for o in objs] - b = Exporter(None, tmp_path / "b", defer_writes=True) - recs_b = [b.texture(o) for o in objs] - assert recs_a == recs_b - assert not list((tmp_path / "b" / "textures").iterdir()) - skip = {recs_b[1]["texture"]} - assert b.write_textures(skip=skip) == sorted(r["texture"] for r in recs_b if r["texture"] not in skip) - ta = tree(tmp_path / "a") - assert tree(tmp_path / "b") == {k: v for k, v in ta.items() if k not in skip} - assert ta[recs_a[0]["texture"]] == png_of(objs[0].read().image) - - def ref_pixels(tex_obj): tt = tex_obj.read_typetree() img = tex_obj.read().image @@ -322,26 +274,6 @@ def ref_pixels(tex_obj): return arr[::-1].copy() -@pytest.mark.parametrize("fmt,mode", [(4, "RGBA"), (3, "RGB"), (1, "L"), (1, "RGBA")]) -def test_packer_pixels_and_png(tmp_path, fmt, mode): - o = tex_obj(FakeTex(b"seed%d" % fmt + mode.encode(), 12, 6, fmt, mode)) - FakeTex.decodes = 0 - want = ref_pixels(o) - p1, p2 = TexelPacker(tmp_path / "1"), TexelPacker(tmp_path / "2") - a = p1.pixels(o) - assert np.array_equal(a, want) and not a.flags.writeable - assert np.array_equal(p2.pixels(o), want) - assert FakeTex.decodes == 2 # the reference, then one decode for both packers - for p in (p1, p2): - p.add("g", o, 0, 0, 12, 6) - p.add("g", o, -1, -1, 3, 3) - r1, r2 = p1.build(), p2.build() - assert r1 == r2 and tree(tmp_path / "1") == tree(tmp_path / "2") - out = tree(tmp_path / "1")["textures/g.png"] - arr = np.asarray(Image.open(io.BytesIO(out))) - assert out == png_of(Image.fromarray(arr.copy(), "RGBA"), optimize=True) - - def test_packed_png_matches_pillow(): rgba = np.random.default_rng(1).integers(0, 256, (5, 7, 4), dtype=np.uint8) assert export.packed_png(rgba) == png_of(Image.fromarray(rgba[::-1].copy(), "RGBA"), optimize=True) @@ -425,7 +357,7 @@ def ref_dump_objects(shaders, out_dir: Path, source: str, index: list) -> list: continue done.add(name) safe = shader._safe(name) - jsonio.write_json(out_dir / f"{safe}.json", shader._parsed_summary(tt), default=str) + jsonio.write_json(out_dir / f"{safe}.json", shader.parsed_summary(tt), default=str) rec = {"name": name, "source": source, "parsed": f"{safe}.json", "variants": []} for plat, si, pi, stage, n, t, kws, code in shader.variants(tt, shader.platform_blobs(o.read())): tag = shader.PLATFORM.get(plat, str(plat)) @@ -561,13 +493,15 @@ def test_decode_parallel_and_cached_equal_sequential(tmp_path, tools, fmt): assert (len(tools.calls) == n) == (run == "hit") # replayed: no tool ran -def test_decode_disk_cache_and_levels(tmp_path, tools): - cache.configure(directory=tmp_path / "c") +def test_decode_memo_and_levels(tmp_path, tools): cri.decode(None, "S", tmp_path / "a", key=0, flac_level=0) - cache.clear() # another process: the disk layer n = len(tools.calls) cri.decode(None, "S", tmp_path / "b", key=0, flac_level=0) assert len(tools.calls) == n and tree(tmp_path / "a") == tree(tmp_path / "b") + cache.clear() + cri.decode(None, "S", tmp_path / "fresh", key=0, flac_level=0) + assert len(tools.calls) > n and tree(tmp_path / "a") == tree(tmp_path / "fresh") + n = len(tools.calls) cri.decode(None, "S", tmp_path / "c12", key=0) # another level: decoded again assert len(tools.calls) > n assert tree(tmp_path / "a")["a.flac"] != tree(tmp_path / "c12")["a.flac"] diff --git a/tests/unity/test_textstyle.py b/tests/unity/test_textstyle.py index 6e15b11..2b7a2f7 100644 --- a/tests/unity/test_textstyle.py +++ b/tests/unity/test_textstyle.py @@ -1,13 +1,13 @@ """textstyle: TMP text records and material style values (synthetic data).""" import json +import struct +from types import SimpleNamespace import pytest -from nnnotes.content import advui from nnnotes.source import languages -from nnnotes.source.config import ConfigError -from nnnotes.unity import fontsettings, textstyle, tmpfont +from nnnotes.unity import fontsettings, textstyle WHITE = {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0} DARK = {"r": 0.2, "g": 0.2, "b": 0.2, "a": 0.8} @@ -256,116 +256,93 @@ def test_no_font_data_in_the_record(): } -def test_text_styles_reads_the_localized_material(monkeypatch): - """The face info and the localized material come from the font settings (no font asset read).""" - monkeypatch.setattr(textstyle, "language_fonts", lambda player: LANG) - face = {"m_Scale": 1.0, "m_PointSize": 100.0, "m_LineHeight": 120.0, "m_AscentLine": 90.0, "m_DescentLine": -30.0} - fonts = fontsettings.FontSettings( - { - "format": fontsettings.FORMAT, - "fonts": {"MainT SDF": {"faceInfo": face, "normalSpacingOffset": 0.0, "boldSpacing": 7.0}}, - "materials": {"MainT - Outline": material(["OUTLINE_ON"], _OutlineWidth=0.25)}, - } - ) - styles = textstyle.TextStyles(None, player=None, mode=TW, fonts=fonts) - rec = styles.record("Canvas/Text", component(), LOCALIZE) - assert rec["outline"]["widthEm"] == pytest.approx(0.25 * 15 / 16 * 0.16, abs=1e-6) - assert styles.summary()["roles"] == { - "primary": {"lineHeightEm": 1.2, "ascentEm": 0.9, "descentEm": -0.3, "spacingOffset": 0.0, "boldSpacing": 7.0} - } - assert styles.summary()["language"] == "traditionalChinese" - with pytest.raises(KeyError, match="MainT - Default of MainT SDF not in the font settings"): - styles.record("Canvas/Text", component(m_sharedMaterial={"material": "MainJ - Default"}), LOCALIZE) - - -def test_font_modes_and_extra(monkeypatch): - assert advui.FONT_MODES == ("open", "game") - with pytest.raises(ValueError, match="fonts must be one of"): - advui.extract(None, None, {}, ".", fonts="other", language="ja") - monkeypatch.setattr(tmpfont.importlib.util, "find_spec", lambda name: None) - with pytest.raises(tmpfont.ConfigError, match=r"nnnotes\[fonts\]") as e: - tmpfont.require_extra() - assert "\n" not in str(e.value) and "fonttools" in str(e.value) - - -def test_the_language_is_an_argument(): - assert not hasattr(textstyle, "LANGUAGE_MODE") and not hasattr(textstyle, "LANGUAGE_FIELD") - en = languages.mode("en") - swap = textstyle.font_swap(LANG, en) - assert swap == {"MainJ SDF": "MainE SDF", "Digits SDF": "DigitsE SDF"} - assert textstyle.localize_text("p", "MainJ SDF", "MainJ - Outline", swap, LANG, en) == { - "fontAsset": "MainE SDF", - "material": "MainE - Outline", - "lineSpacing": -100.0, - } - assert textstyle.font_swap(LANG, languages.mode("ja")) == {"MainJ SDF": "MainJ SDF", "Digits SDF": "Digits SDF"} - style = textstyle.material_style(material(), point_size=100.0) - assert textstyle.text_style(component(), LOCALIZE, LANG, style, en)["lineSpacing"]["applied"] == -100.0 - - -def test_story_ui_language_records(tmp_path): - assert advui.about("en") == "ADV front canvas UI data (UIAdvWidget + UIDefaultTalkWindow), en" - assert advui.language_doc("en") == {"mode": 1, "field": "english", "lineSpacing": -100.0} - assert advui.language_doc("zh-Hant") == {"mode": 2, "field": "traditionalChinese", "lineSpacing": 5.0} - assert list(advui.language_doc("ko")) == ["mode", "field", "lineSpacing"] - # an unknown language is refused before anything is read, naming the setting - with pytest.raises(ConfigError, match="catalog.language: not one of the game's languages"): - advui.extract(None, None, {}, tmp_path, language="xx") - - -def test_shown_texts_take_the_language_field(): - def lines(ja, en): - return {"japanese": ja, "english": en} - - episode = { - "commands": [ - { - "cmd": "Talk", - "TargetName": "a", - "AdvTextID": "t1", - "TargetTextIDs": ["n1"], - "lines": lines("ja1", "en1"), - }, - {"cmd": "Talk", "TargetName": "b", "AdvTextID": "t2", "TargetStatus": 1, "lines": lines("ja2", "en2")}, - { - "cmd": "Talk", - "TargetName": "c", - "AdvTextID": "t3", - "TargetStatus": 2, - "TargetTextIDs": ["n3"], - "lines": lines("ja3", "en3"), - }, - {"cmd": "Location", "lines": lines("jaL", "enL")}, - { - "cmd": "Talk", - "TargetName": "d", - "AdvTextID": "t4", - "TargetTextIDs": ["n1", "n2"], - "lines": lines("ja4", "en4"), - }, +# -------------------------------------------------------------------------- +# LocalizeManager: the serialized bytes, the object json of the shipped type tree +# -------------------------------------------------------------------------- +ALIGN = 0x4000 # the align flag of a type tree node +LOCALIZE_HASH = "597220768ec4996f80d7cfd5e139645f" +LOCALIZE_LANGUAGES = [ + (0, ["A-OTF-Main", "VibeMOPro"], ["NotoSansJP"]), + (2, ["TW-Main", "Digits"], []), +] +LOCALIZE_MATERIALS = ["Default", "Outline"] +EXPECTED_LANGUAGES = { + "languages": { + 0: {"fontNames": ["A-OTF-Main", "VibeMOPro"], "additionalFontNames": ["NotoSansJP"]}, + 2: {"fontNames": ["TW-Main", "Digits"], "additionalFontNames": []}, + }, + "materialTypes": ["Default", "Outline"], +} + + +class _Out: + """Serialized fields as the reader of textstyle reads them: little-endian, each string aligned to 4 bytes.""" + + def __init__(self): + self.raw = bytearray() + + def i32(self, v: int) -> None: + self.raw += struct.pack(" None: + self.raw += struct.pack(" None: + self.raw.append(v) + + def align(self) -> None: + self.raw += b"\0" * (-len(self.raw) % 4) + + def string(self, s: str) -> None: + data = s.encode("utf-8") + self.i32(len(data)) + self.raw += data + self.align() + + def strings(self, values) -> None: + self.i32(len(values)) + for s in values: + self.string(s) + + +def localize_manager_bytes(languages_, materials) -> bytes: + """The serialized LocalizeManager: its MonoBehaviour header, _dontDestroyOnload, languages and materials.""" + out = _Out() + out.i32(0) # m_GameObject + out.i64(0) + out.u8(1) # m_Enabled + out.align() + out.i32(1) # m_Script + out.i64(7) + out.string("LocalizeManager") # m_Name + out.u8(1) # _dontDestroyOnload + out.align() + out.i32(len(languages_)) + for mode, fonts, additional in languages_: + out.i32(mode) + out.strings(fonts) + out.strings(additional) + out.strings(materials) + return bytes(out.raw) + + +class _Boot: + """Boot data with one MonoBehaviour: the LocalizeManager of the serialized bytes `raw`.""" + + def __init__(self, raw: bytes): + self._by_type = {"MonoBehaviour": [SimpleNamespace(get_raw_data=lambda: raw)]} + + def script(self, o) -> tuple: + return ("Fwk", "Fwk.Localization", "LocalizeManager") + + +def test_language_fonts_json_is_the_same_dictionary_from_the_object_json(): + doc = { + "_dontDestroyOnload": 1, + "_languageDataList": [ + {"_languageMode": mode, "_fontNames": fonts, "_additionalFontNames": additional} + for mode, fonts, additional in LOCALIZE_LANGUAGES ], - "text": { - "n1": lines("名1", "Name1"), - "n2": lines("名2", "Name2"), - "n3": lines("名3", "Name3"), - "unk": lines("?", "???"), - "split": lines("・", "&"), - }, - "title": lines("題", "Title"), + "_materialTypeList": LOCALIZE_MATERIALS, } - ids = {"_unknownCharacterNameTextId": "unk", "_splitCharacterNameTextId": "split"} - assert advui.shown_texts(episode, ids, "english") == [ - "en1", - "en2", - "en3", - "enL", - "en4", - "&", - "???", - "Name1", - "Name2", - "Title", - ] - assert advui.shown_texts(episode, ids, "japanese")[:2] == ["ja1", "ja2"] - with pytest.raises(RuntimeError, match="no title"): - advui.shown_texts({**episode, "title": None}, ids, "english") + assert textstyle.language_fonts_json(doc) == EXPECTED_LANGUAGES diff --git a/tests/unity/test_typetrees.py b/tests/unity/test_typetrees.py index e62edc1..e1a0ceb 100644 --- a/tests/unity/test_typetrees.py +++ b/tests/unity/test_typetrees.py @@ -12,17 +12,8 @@ from nnnotes import cli from nnnotes.source.config import ConfigError -from nnnotes.unity.player import ( - HEADER, - TYPETREE_FORMAT, - PlayerData, - UnsupportedVersion, - dump_typetrees, - load_typetrees, - manifest_version_name, - typetree_key, - typetree_node, -) +from nnnotes.unity.player import HEADER, PlayerData, UnsupportedVersion, manifest_version_name +from nnnotes.unity.scripttypes import TYPETREE_FORMAT, dump_typetrees, load_typetrees, typetree_key, typetree_node from tests.helpers import synth TYPETREE_DIR = resources.files("nnnotes.unity").joinpath("typetrees") @@ -524,3 +515,16 @@ def test_unity_rows_refuses_what_it_has_no_layout_for(fields, message): gen_typetrees().unity_rows(GEN_HEADER + fields) with pytest.raises(ValueError, match="unexpected MonoBehaviour header"): gen_typetrees().unity_rows(GEN_HEADER[:1] + GEN_HEADER[5:]) + + +def test_aligned_fields_get_the_align_flag_and_missing_ones_are_refused(): + g = gen_typetrees() + rows = [[0, "MonoBehaviour", "Base", 0], [1, "UInt8", "_dontDestroyOnload", 0], [1, "int", "count", 0]] + assert g._aligned(rows, ("_dontDestroyOnload",)) == [ + [0, "MonoBehaviour", "Base", 0], + [1, "UInt8", "_dontDestroyOnload", ALIGN], + [1, "int", "count", 0], + ] + assert g.ALIGNED_FIELDS == {"LocalizeManager": ("_dontDestroyOnload",)} + with pytest.raises(ValueError, match="no root field \\['missing'\\]"): + g._aligned(rows, ("missing",)) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..68a4438 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2135 @@ +version = 1 +revision = 2 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "archspec" +version = "0.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/26/7bb7a53f4e246908fdc1810f4cbd6c8d70ce6a924e6c50f0269c5e792c1c/archspec-0.2.6.tar.gz", hash = "sha256:2ea980955530499865225eff917b649b39fd014b53f8c6461b6348c1d8b866f1", size = 59191, upload-time = "2026-05-14T19:10:25.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/12/7f9edf5a5a547ce0421e8f120c157b3602eabb12147ffea95eefbd55a94e/archspec-0.2.6-py3-none-any.whl", hash = "sha256:22463371fe8e9fa92fa144bf320cb445373a99f2ca6a5914c890e0c0b7816f8f", size = 81818, upload-time = "2026-05-14T19:10:23.538Z" }, +] + +[[package]] +name = "astc-encoder-py" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "archspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4f/1e/fca57bdb9b429a1d2e2f83ad0621b4ab6af65ee5fefc38a4f318afad0d8e/astc_encoder_py-0.1.12.tar.gz", hash = "sha256:03759ac32804cae32bc238e72f94c5ba27e228507df00816b741f7961f1184f9", size = 206268, upload-time = "2025-09-06T16:04:11.2Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ee/36a2660bb7dc6dcfe1f1c8458c207b08f86917250398dde214fe7e404591/astc_encoder_py-0.1.12-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0274f444fd7630f86eee4b76030322ac38508ab81b102e93e3699d17a88f5d4e", size = 551079, upload-time = "2025-09-06T16:03:05.779Z" }, + { url = "https://files.pythonhosted.org/packages/96/6c/096111aa3b31149edf384f24272086ca91d224f0fb2dcdeb2c21cf47ffd7/astc_encoder_py-0.1.12-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b7fdd11d997610da9e64a91bb031a5e18f41cfd20315763019fb14227b461c85", size = 272780, upload-time = "2025-09-06T16:03:07.689Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/7ac038d6883a1e90026cfc41fabd9806f5212aa9b0e6ab1bb3d88af348ea/astc_encoder_py-0.1.12-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feada45aea860f0e6db9c0bcec2010efe86ae4a97a04a7b9e649c3f5da5b9723", size = 2252158, upload-time = "2025-09-06T16:03:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e1/aca2bf4832d4cc808f3e9ddd73bfc0e9dc810779d4c6d5a2d853fa58dc4a/astc_encoder_py-0.1.12-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3dc9420d1b601855f0cb915e14bd825823ee1dbdffcbe2ae7597226a38b08eda", size = 1204522, upload-time = "2025-09-06T16:03:10.679Z" }, + { url = "https://files.pythonhosted.org/packages/c3/13/c47dfc3d394b5765e91642c2513ff4934f934454c76d2e8ac905677fe5e7/astc_encoder_py-0.1.12-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c632b45d07f81219d1c0f8c6c06e30fc0af6d4c1fecfa48c15f472ef62efcf96", size = 1206377, upload-time = "2025-09-06T16:03:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/60/97/8c9da0ce2cfd362b5eab0ef9bce1b92169ff950bc1a0a5c87a156e29ec2a/astc_encoder_py-0.1.12-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a869428b00ab7e3ba5ef65a64b5906bbd0f3e129e488e1d55fe6ed2614dd3860", size = 1205416, upload-time = "2025-09-06T16:03:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/08/a3/4a0399ef6f14cf3738eafb14cc89d890bdc8eae2b5c5eaaf6ab74495c9af/astc_encoder_py-0.1.12-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a3c0efc3187e06fd1d5992a986220a5921498c35c44d1edbd9b2971c71a8a4f", size = 4764225, upload-time = "2025-09-06T16:03:16.148Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2a/74134d748d8504da07a95124862ecc34392c61902590c1ff398c89c4dc35/astc_encoder_py-0.1.12-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7252fff9cc150f42c134223c7efdabe5e97739c30d4a5ea0ff2745696d81939e", size = 2941309, upload-time = "2025-09-06T16:03:18.46Z" }, + { url = "https://files.pythonhosted.org/packages/b5/89/de46397bcbe56e46e1cad62a1caf79989e02858dd9451143326a2cf23687/astc_encoder_py-0.1.12-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:805d0a891127818a9b4ccbc087a004b9d1409de2f94236220f8901759d917229", size = 1847384, upload-time = "2025-09-06T16:03:20.159Z" }, + { url = "https://files.pythonhosted.org/packages/40/e0/f71de2c2e6e3c8a8fcf3bd493bc357ca1d0140a5a36081f4dec42db0ef36/astc_encoder_py-0.1.12-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:e9963785b87cea2f319bf95d392be2a6d6f0420d5104d3ddf26072024d6675b7", size = 2211716, upload-time = "2025-09-06T16:03:22.269Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/97a347f3a9baa702a27a210fb01c47513f75d4d99e9a9e6eb18491288ad2/astc_encoder_py-0.1.12-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2afe823dfeac1e5d9c1764802bc26ceb3b29dcc805c55dd900dd03d484206456", size = 2124291, upload-time = "2025-09-06T16:03:23.838Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5d/0c05bc5c64288952fdf3b339b512f670ca5e9a432a4a77a33894577d1d69/astc_encoder_py-0.1.12-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:782ec2694f314b7b968986615fe55c3429e3dcd2eaf9ac2fecf13d66a5de61db", size = 2240620, upload-time = "2025-09-06T16:03:25.942Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ec/3c21d30d6d7f9c90c6d5c3dcadfe53b37f5e03470ee160f16e82bed4328b/astc_encoder_py-0.1.12-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:39c02d2e74d02169622664fb3d39764de93d8ce8eae61f6b2228c8ae1e7e8aac", size = 5297371, upload-time = "2025-09-06T16:03:28.599Z" }, + { url = "https://files.pythonhosted.org/packages/0c/66/ddee8e1b6b543673dfbe364a75f05a7772a5a1212f394ffbecb3a8a2591e/astc_encoder_py-0.1.12-cp37-abi3-win32.whl", hash = "sha256:c291b70799c1839f96a4984024f564bbf143046efef37ccb3a628866dcfc3c4a", size = 571603, upload-time = "2025-09-06T16:03:30.004Z" }, + { url = "https://files.pythonhosted.org/packages/81/36/9c36fdcedc314f514298f602661518a1525bcb37d81c1197fc290bce8484/astc_encoder_py-0.1.12-cp37-abi3-win_amd64.whl", hash = "sha256:a45d375fc6b0ff2cd3d772aab3eef19a6b7f3e067306e11fab37820ea9576121", size = 2533265, upload-time = "2025-09-06T16:03:31.798Z" }, + { url = "https://files.pythonhosted.org/packages/fb/25/3dfdf39d4ce0c5e0da4ec715377aee11d4462a14e40ff309b9c66e9bbee0/astc_encoder_py-0.1.12-cp37-abi3-win_arm64.whl", hash = "sha256:7615108b986a50550bbd01b148eb98e54da5b12502962cf17b4bd87fa613b314", size = 1203272, upload-time = "2025-09-06T16:03:33.563Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/c4b75b0279b2cf9e208c7dea5755a452d158a32abe1b7330824ff1127e86/backrefs-8.1.tar.gz", hash = "sha256:f86d9312df3ebae1241758af80809a319cafa1d8f32189edfb242c9aa55573b6", size = 6999089, upload-time = "2026-10-10T13:29:24.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/17/7be730f397b9239be5044a147f0cdfbd9605111b07af053c737b21d79d16/backrefs-8.1-py310-none-any.whl", hash = "sha256:438bd38dc7fa0cded52786a1351a2dad9efc2cda95d047284c41d916a22312a8", size = 380382, upload-time = "2026-10-10T13:29:12.764Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d1/d25387b14119b7062fead6c9b2e87c2ab286beb346df79c6a649c095e979/backrefs-8.1-py311-none-any.whl", hash = "sha256:11f61846e446fa8637af2f5c073f1083aa58dbb7460b019fd51689f564e44f7e", size = 392180, upload-time = "2026-10-10T13:29:14.729Z" }, + { url = "https://files.pythonhosted.org/packages/7d/63/4c3078fcbb3956c55b24472d8375a1b7ad67203d4a1917512eee34d4fbee/backrefs-8.1-py312-none-any.whl", hash = "sha256:b472c76082769f86a97ffccfd1f2599b2b706610c6154955664fffe67e909035", size = 398092, upload-time = "2026-10-10T13:29:16.364Z" }, + { url = "https://files.pythonhosted.org/packages/df/22/60f30c9b8e497ca4bbac23991a726ebc267e23771a1b9d9c04b3a3c16dd6/backrefs-8.1-py313-none-any.whl", hash = "sha256:db577dbae1c61187534d35fc9bfbd0c01158fff6239a66fecf7590e5b17879a9", size = 400050, upload-time = "2026-10-10T13:29:18.09Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/80fa46ecb47717845ab9c07bce8651ef8f80aac5ec698e212d02ed2a9963/backrefs-8.1-py314-none-any.whl", hash = "sha256:1e857c32e91b8fe46ddb359ac21021a37b090fa4239566a61e9856123e334041", size = 411908, upload-time = "2026-10-10T13:29:19.876Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/a57c5a5b7461b8c88266244014aa38052d76f64b064451b1d5342eb09bf4/backrefs-8.1-py315-none-any.whl", hash = "sha256:9c8fe3e2eaae4301a9ce787341d986e5ba25a887d89da6de0de5d58eca26a949", size = 417589, upload-time = "2026-10-10T13:29:21.821Z" }, +] + +[[package]] +name = "boto3" +version = "1.43.114" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/8c/f6f884dc947789317e73ed6fce85e18580d22e9f90e48d67c2367b02667e/boto3-1.43.114.tar.gz", hash = "sha256:be704857751564a5cf69c5bbaadbfa01c22806409815c73563db42fbffe583a2", size = 112653, upload-time = "2026-10-14T19:24:22.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f8/0799a101e6f65c8b687f50c218654cef1e44658e946c7d33d362e2572621/boto3-1.43.114-py3-none-any.whl", hash = "sha256:d9cac2eb921ce674970cef1c9ad750f85ee3a846aedcf188d18368fb9eb6da23", size = 140043, upload-time = "2026-10-14T19:24:21.038Z" }, +] + +[[package]] +name = "botocore" +version = "1.43.114" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/c8/b508359d1f3846a918c06807a9ae27eee063f904559269e42ccde9de09ea/botocore-1.43.114.tar.gz", hash = "sha256:f366fa4db518775632ad1eb128cd8203ca46396cecf37209d904f0bbc049ce90", size = 16369844, upload-time = "2026-10-14T19:24:17.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/41/7c6fa7ac5fcfd5ea3c6f32aab001942da32b184a210f39042778cb1ad8ed/botocore-1.43.114-py3-none-any.whl", hash = "sha256:d1c441a22e93e158de5b1e026205f5d6d67a4545d10540c5090c62dccb3a9eca", size = 16067885, upload-time = "2026-10-14T19:24:14.629Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "etcpak" +version = "0.9.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "archspec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/c504ccfe89dbff7154a7733b6792885b3af88a6a5db534b381ae6c3048fd/etcpak-0.9.15.tar.gz", hash = "sha256:fe09e32d8aa75b8163832428c3d6df65a51b2b7e0237292c10c1224ca5728a43", size = 118748, upload-time = "2025-08-11T21:58:01.32Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/cb/d302b8aaa77ec2b8b468731237d72d6832c96a18b944c0a34f21eacd4d79/etcpak-0.9.15-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ead1fa47527b9d216be50f65ab4cc728ced93dafea773139026ad6268934fb6f", size = 368722, upload-time = "2025-08-11T21:57:06.519Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/501e3c4307ce022d2522af84e78dd03c4e225870f7484ccb5d4302b7c0ec/etcpak-0.9.15-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b96260c18ff79387d9b003c9188196c2490bdcd3420496fcf544f246a534c31b", size = 185525, upload-time = "2025-08-11T21:57:08.591Z" }, + { url = "https://files.pythonhosted.org/packages/80/0a/afd05444fe8fc3db906f57580be711c0a6bc39375ca7a738f04e7e1fc947/etcpak-0.9.15-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c4c6ebe3a5aaf907afa14619071cd5bc60857f175491d04cbacedbba561d5c", size = 1170402, upload-time = "2025-08-11T21:57:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c9/cae71b16a2d7f70012f7c61d7cf225cbd4ae10c5401aded3b6cb3c14e9c4/etcpak-0.9.15-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:13954e177dfc0c0643c6069fd9568dc4116cfb0a5cbe5657be93b94bed41c529", size = 551698, upload-time = "2025-08-11T21:57:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c5/c3530a189a7fde543d77f2c3bbf2fd370497346b0b4d857e4163e6099b06/etcpak-0.9.15-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b168f8d3ebad00ade491a8e3a6a86e47356a943337ab3cccff1c605ac664b525", size = 579230, upload-time = "2025-08-11T21:57:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/72/69/a3ee48aa9f7f3a7cbbdd2911f80dbc39cefff9dda8744e5f0c8123503995/etcpak-0.9.15-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7cc0746ddc2220e84982b48ce62df17a08dfc38ac77bd878b5835139765be8e8", size = 662066, upload-time = "2025-08-11T21:57:14.099Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b1/07d7955b6c25aa5c56c427125a32ea8431e61edf4df79a54e14329b3a7f7/etcpak-0.9.15-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b75248c4869c7fddf1e5bba4be02a504da08cffb17434dcdb5bc618869d4ea0a", size = 2610903, upload-time = "2025-08-11T21:57:15.719Z" }, + { url = "https://files.pythonhosted.org/packages/e7/59/e057c7d09506ec462c9b9d7323a8bf3155692effbaa6af68d6417b2b05f0/etcpak-0.9.15-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b7407c83db1b73be12816d56e3ed75a44f39553327aa5a3613b10839c891bac7", size = 2006710, upload-time = "2025-08-11T21:57:17.174Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2a/e6bd68d717aafaa4df7417b0e8aa50a0e57ab1efad55dafc401d81a0701e/etcpak-0.9.15-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:693d4a73451b2d8b3f13da372fae071a2a1a0ac01c783d02c8e000abe8ee4370", size = 1321875, upload-time = "2025-08-11T21:57:18.893Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/28839b77a701c6d478ba82a38c5de294223e34d393f602b65df1f0908ceb/etcpak-0.9.15-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:b5c794bfe0cb3d8a1d4ff5d823e413b5ac25486a9d995505d785ee4d8886887e", size = 1620716, upload-time = "2025-08-11T21:57:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/2b/01/26b52b7371e3c321c1b064b67b2373be13cd4568ab1881f087ac0aa32285/etcpak-0.9.15-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:3029d8736e381579c679aa2d6a303d0123110392847d1b932dc142328f63ad8b", size = 1606327, upload-time = "2025-08-11T21:57:22.343Z" }, + { url = "https://files.pythonhosted.org/packages/36/7e/db313295f76d32552c64138d07a40cc04f579e01a2e64755c3bd40aee91a/etcpak-0.9.15-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:2ccd4b43d78ecbf4f53c55343daefdf317947f2ae46537cfd5f0e29414c3c5f5", size = 1808211, upload-time = "2025-08-11T21:57:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/db/2c/83e1eae30717fc7f81562a72d2dcf9e47954bd3fdab8d883bc25a7c6be0f/etcpak-0.9.15-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:34406279b664aae890fef96f5175cc68bb48b2cb0440c076ab37fb591b181f68", size = 3505883, upload-time = "2025-08-11T21:57:25.394Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c7/067be73bcaddb97c530f105b526ea08488c0793b36e853f6a032f1a78868/etcpak-0.9.15-cp37-abi3-win32.whl", hash = "sha256:34bee5cf945e12efa76728aca448932229bd9496c691d5fe12e20783446fd446", size = 225905, upload-time = "2025-08-11T21:57:26.795Z" }, + { url = "https://files.pythonhosted.org/packages/65/d5/6659ba74d4fec78a46dcf4ae6780c6fac34acf041c8e4d63bba709995a4d/etcpak-0.9.15-cp37-abi3-win_amd64.whl", hash = "sha256:669d902eeb774bc6d4df226183c09c3d03c9fc0f47b4c0c20030fc2b39eec2f2", size = 1132728, upload-time = "2025-08-11T21:57:27.974Z" }, +] + +[[package]] +name = "fmod-toolkit" +version = "0.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyfmodex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/00/34fd772fcffb7ebc96d795ed47d6f4140c6de45aa30b0e21e73d361853bb/fmod_toolkit-0.1.3.tar.gz", hash = "sha256:e7c16c90c23b13726b78eda562563bc7463098baa7820dc33fae94411a2e1c7f", size = 5799501, upload-time = "2025-10-03T21:31:46.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/5a/e974f1196f47d6a0864b9ac0b1610867090c1b6f0a8211a41ddf14ec3c20/fmod_toolkit-0.1.3-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c6c5a1c3db73e5d12d34760805e87aa85f493101e5ff4ef1a595407258c94dc3", size = 1151250, upload-time = "2025-10-03T21:31:26.38Z" }, + { url = "https://files.pythonhosted.org/packages/3a/15/ea58b59d424f6a5751c555acc9b31d65afff5d6db2b80979877751c58657/fmod_toolkit-0.1.3-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:c54c6503ee7b99522a7bee9463b706bc8ed68c3cbdf2b214203a13668526802b", size = 1151247, upload-time = "2025-10-03T21:31:28.126Z" }, + { url = "https://files.pythonhosted.org/packages/5b/56/3d47e442893373e76ac3f81584f823a9395ac313814bcac878612210ec75/fmod_toolkit-0.1.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8bdd31ca28e9cfc9ae3d22845d49efb268e1113fa56d48412e32f661e5401da", size = 620026, upload-time = "2025-10-03T21:31:29.787Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/094a160e82ed5c5d9de100b9c36e3283fed27916838707525439bc225f4d/fmod_toolkit-0.1.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:190b29dbaaee97b6576bb77876f12b30e29f4f52ebbf631c14f9d61f4b3ed4df", size = 699985, upload-time = "2025-10-03T21:31:31.243Z" }, + { url = "https://files.pythonhosted.org/packages/ab/db/b11623615ddbdda3f5c68acd04da2b25ae32fc7668c488538c4092f4f1a2/fmod_toolkit-0.1.3-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ea99e5b8792e28c6a678ba55d929737c0a93faea0b66998f2bbadd09e06065d", size = 700410, upload-time = "2025-10-03T21:31:32.943Z" }, + { url = "https://files.pythonhosted.org/packages/39/8d/9274a1638295dbb1509b44a77ede762206586518bab715666e116c39d185/fmod_toolkit-0.1.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:65d802674ac049b21b93ab154aefc1523a28f599c9ba3809a96daca93b75ba23", size = 1566363, upload-time = "2025-10-03T21:31:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9e/4b0201328bf5be8e7d82ce4960ceac78fd0d62678e4ec09fb334266e7776/fmod_toolkit-0.1.3-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e2b3b8707818a4064a08e86fa2196d071771391153d58e0bd5c2d6c7a29a3a90", size = 1444907, upload-time = "2025-10-03T21:31:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/b56db0996e39df6dbd7045bd6829299069426604930e613103911887e54f/fmod_toolkit-0.1.3-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:e3be1e00779949dbec24b8c5d45bf3c73b495881e815532a7d73fbc245d592c6", size = 1781168, upload-time = "2025-10-03T21:31:38.375Z" }, + { url = "https://files.pythonhosted.org/packages/40/8a/37fb7a9c5d05a4f38989852eaf8d13cea4c3902388023d76155903eabe47/fmod_toolkit-0.1.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e135b5f2d708c3b244caac79a29daed17b63de9e7ba1e874608a40ecce6e7467", size = 1691242, upload-time = "2025-10-03T21:31:39.841Z" }, + { url = "https://files.pythonhosted.org/packages/d7/96/236dffd44b50542f24129afaa61f33d12f7d95aaf2982e96490985b1495c/fmod_toolkit-0.1.3-cp38-abi3-win32.whl", hash = "sha256:a7dc63804c93e296769295a04536b8be6df30a5674b421fbd367767f647fb9a8", size = 703667, upload-time = "2025-10-03T21:31:41.701Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6b/608e1f9467fa54e370b9643d25d9be21e10cc1370cd4261f828a2c5e932a/fmod_toolkit-0.1.3-cp38-abi3-win_amd64.whl", hash = "sha256:33256c13ab84deadc443c6fd428938827469b0df964261c314863b430239dee2", size = 795705, upload-time = "2025-10-03T21:31:43.429Z" }, + { url = "https://files.pythonhosted.org/packages/20/10/c8da14d568aa7fdc3572b3079029ddb0d9b1489ce31007785841a83add05/fmod_toolkit-0.1.3-cp38-abi3-win_arm64.whl", hash = "sha256:da971a4c788da79b7c8d12769bc5ae0a4c8f08b359d4caab57019c679f5b6c4a", size = 572113, upload-time = "2025-10-03T21:31:44.79Z" }, + { url = "https://files.pythonhosted.org/packages/82/e9/149f2a55dbb079a8bdc3dcc5dd2a5b0886ae402c128fd50dbacf2a74ccc2/fmod_toolkit-0.1.3-cp38-abi3.abi3t-macosx_10_9_x86_64.whl", hash = "sha256:6c0a7ff0d6ea87cc424cfe13d9062e29791868658dc4dd93c17b2e34394f00a9", size = 1151254, upload-time = "2026-04-07T09:49:38.277Z" }, + { url = "https://files.pythonhosted.org/packages/35/43/9bbd3e2191b7a844fa3a584d505fd4be0cff408e44f2c6ea91cdd4cfd87e/fmod_toolkit-0.1.3-cp38-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:cd5ae416f63b2d2a68b7eeef511f43e69e27576626e46195e02c72f829d6ff0f", size = 1151255, upload-time = "2026-04-07T09:49:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/24/e8/91a6fd525202e07d823fc68f643b80738da0803d9f5be85118cb2572bd26/fmod_toolkit-0.1.3-cp38-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5abeadaba90a7620fa1cf49bde0566545dc9aac644136536c642e1dd8f039fde", size = 620032, upload-time = "2026-04-07T09:49:41.604Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/084850bac391a6b60f34fd7ed95585b96215273fe9caeeb60036f9e1de8c/fmod_toolkit-0.1.3-cp38-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f7d553b4d3695eca8bdd94d03bce35ef58a9c2938b87b202aed24dc6bf2b4a3b", size = 699999, upload-time = "2026-04-07T09:49:43.28Z" }, + { url = "https://files.pythonhosted.org/packages/26/2f/116a602d7d0d21c3082088fd1d0b9e8d56ac63aed70560f3a050b907e3d8/fmod_toolkit-0.1.3-cp38-abi3.abi3t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1d97d9c2c69528a8b5696a04a86d178569c7bac5fadeb4e28aa92e19885aa6c", size = 700422, upload-time = "2026-04-07T09:49:45.002Z" }, + { url = "https://files.pythonhosted.org/packages/dc/aa/a2fa962810d220dab152bc5e6fffcde0dd614baab5044f8c8a3c12651e63/fmod_toolkit-0.1.3-cp38-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:ea6786f64e8de20d21f984aa3509304d0f31735c09fca6a4295ee5f264dff9ad", size = 1566370, upload-time = "2026-04-07T09:49:46.819Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/e8b5d593c961948d99f5df7210e1c84fd00c2a935eb07660db1205e432ca/fmod_toolkit-0.1.3-cp38-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:7de8941eb6d4c87e4fd5bf95d11afb9f41648f492dc4111fc7498dc82447b88c", size = 1444912, upload-time = "2026-04-07T09:49:48.726Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8d/e9cd0c3d818890fd16f0e4c659ffc632a27a52a5093c3ef51b22cadfd92f/fmod_toolkit-0.1.3-cp38-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:c27b0b3d919bd9cdad097395b4f563af70f727c1ec9f96d8891631af80c7333b", size = 1781172, upload-time = "2026-04-07T09:49:50.516Z" }, + { url = "https://files.pythonhosted.org/packages/94/ee/a0bbd92441c6445d0ae8047854cbeaf54516aef9d64f499c66ac5370b66d/fmod_toolkit-0.1.3-cp38-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:a8549951dcda9eff4f2b910b6ea3412cc9a7d0e7b087d41b6483d2dc04c65240", size = 1691247, upload-time = "2026-04-07T09:49:51.863Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a5/7378717833a57b7a3f67d4f65421482fc7d1ace2cd7e7cc92e96ff4b63ae/fmod_toolkit-0.1.3-cp38-abi3.abi3t-win32.whl", hash = "sha256:8e7c6443863f08b2e0ec934126e276c110d3587cd89dfae92ee1368ddbede288", size = 703675, upload-time = "2026-04-07T09:49:53.338Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3d/0bc89ec60114514bae299c589ebece9b46361e6f6b64d521f905a029b855/fmod_toolkit-0.1.3-cp38-abi3.abi3t-win_amd64.whl", hash = "sha256:a56432c291d0cfbf9e88dcb94e144bc9f244672590871398f8e1891d5d6f3c2e", size = 795709, upload-time = "2026-04-07T09:49:54.726Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d9/b88502975802d16fb250d7dc76067ec0f18c27321e525a6f650cb63a9c0b/fmod_toolkit-0.1.3-cp38-abi3.abi3t-win_arm64.whl", hash = "sha256:4266b36c0be5cadad4c7ba75d737bf3fbe1fd1cbd9895e255a4c5c0a62cc5a5e", size = 572121, upload-time = "2026-04-07T09:49:56.38Z" }, +] + +[[package]] +name = "fonttools" +version = "4.67.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/36/102e180f8f5dbaee88b26595b01ca8aa80bf4e62128d9aa94265b3996c96/fonttools-4.67.0.tar.gz", hash = "sha256:3cb57e6600ca77c0b1729cf8adc23bc0652633a37f18cfa934d9c7bc3de25519", size = 3750028, upload-time = "2026-10-14T13:20:28.294Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/a5/723340838581bbed0590429662750dc70d67ba671947b7d5fa06a4e15c19/fonttools-4.67.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47dba566b4f475b0fb5f83129487c21b6a6a4edc41c0eec52524f969a68a3d45", size = 3100299, upload-time = "2026-10-14T13:18:21.068Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fd/71b5a2eb0549ffcfa06da1628b44c9a0519a66e72805651a1826181e19ce/fonttools-4.67.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5377e0e991e3e2be47fd1215414b20c2288b546e5a8c6d80b1a7cde9c72a89e1", size = 2597611, upload-time = "2026-10-14T13:18:23.814Z" }, + { url = "https://files.pythonhosted.org/packages/74/70/13597ab012385760db2f0b4a21b8c528c4a4132d936cc1393cb4f8c645be/fonttools-4.67.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:690ab72d338aa9bf8e5cd9aefb86e0d3c458d8b9de4df041fb7dc2ed4703144e", size = 5495357, upload-time = "2026-10-14T13:18:26.276Z" }, + { url = "https://files.pythonhosted.org/packages/b3/74/6117d6bec5736133fffd5cc4500426ddd43c761df9b380c796cd2268c069/fonttools-4.67.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:59f44309ce78851c9621ee88e3f667ca3fbcc89dc0e8641336be3f12ba06bfd4", size = 5458613, upload-time = "2026-10-14T13:18:28.516Z" }, + { url = "https://files.pythonhosted.org/packages/0d/12/a6762909cb4e48891bba5fbe3b18d08f867df591dcc57ab7e5c5a037bb9d/fonttools-4.67.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:621b3152b5d0412381b792bacfe410ac1f09c2c4f28a44bd19d26fe7160cfc96", size = 5461153, upload-time = "2026-10-14T13:18:30.878Z" }, + { url = "https://files.pythonhosted.org/packages/93/35/8287d95ca9e99398e9b5a5692b7b088957bfc1d1149555b0f4a2b11a8455/fonttools-4.67.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5ad690ea5bfd8913d1a6e5d5e9825ccf4ed342716e63c2b0d7f490d50235daef", size = 5598976, upload-time = "2026-10-14T13:18:32.982Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/e8839e592f8f29cc18a3a4e4e87ab85ae69248c7ab77a28476b7ba958ea3/fonttools-4.67.0-cp311-cp311-win32.whl", hash = "sha256:3fb95166eaebad72f9deb1d0d781f652525f47e4693e553dad3954cf68ed6e9c", size = 1588706, upload-time = "2026-10-14T13:18:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/24/73/5c281531cf7899ae37a0937c62feed1f7d0e8a35538eea4d1595b52447e1/fonttools-4.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:33ae23a531795864fcdbbab91a40c824976e22642c05efca3bd8a0b00630d0e7", size = 1646300, upload-time = "2026-10-14T13:18:37.157Z" }, + { url = "https://files.pythonhosted.org/packages/5b/50/f674402869f11a89868c4755ae86cd2fcfd67ca6193c6f5d1b479b1267b9/fonttools-4.67.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fcb9743140419410161acfe7ec205fb0a8a703acfccb85b586becb5a97c047c9", size = 3108316, upload-time = "2026-10-14T13:18:39.162Z" }, + { url = "https://files.pythonhosted.org/packages/e3/c8/5963603c5f9bbc28bde3a29dd7cdbe0bfcbee414b0f7eccec04ae477e1b6/fonttools-4.67.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ad813967410ba6d24a52850df59b164ee17883f17b96a91b4b0ac6e9d7b5a118", size = 2598450, upload-time = "2026-10-14T13:18:42.136Z" }, + { url = "https://files.pythonhosted.org/packages/25/6d/f8e5924917a6b5c0296fb507f748c139a34972f66e91d89159d5c98e27b2/fonttools-4.67.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:768a33bbe6ec5ba8f19979f938752f06d4e614cb554fd47abd7830f2007660e3", size = 5427391, upload-time = "2026-10-14T13:18:44.248Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e0/ec9e4cc868c514deb02233aa1047a6aeb9350d3ee012862f58eec10ef834/fonttools-4.67.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb3c98cac93aac4b9f6e3ce2008325340b234cc9b0338ca6b513f31962a1e278", size = 5408233, upload-time = "2026-10-14T13:18:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4a/fe409cb3ab32f322de92e08e6362cd06bf6dd5f0cee5980d823849e9bd11/fonttools-4.67.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e0ca4c8438dd6320f5850c9bbee3b3980455ee3bac602a9a0299caf9e799a0e8", size = 5364778, upload-time = "2026-10-14T13:18:48.926Z" }, + { url = "https://files.pythonhosted.org/packages/de/5b/2a8dede092113be56329dd210deb6b34c55df2f3d7270934ffece8c7d0bb/fonttools-4.67.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2a09d33a9264a6b29efca9dc633b53969aaedb250a9c8521d60f51280cef65ca", size = 5528522, upload-time = "2026-10-14T13:18:51.297Z" }, + { url = "https://files.pythonhosted.org/packages/6c/de/d3baf686e4ac5726a24819a670747c51571c774dcfa41cc0528e5e8c1a2d/fonttools-4.67.0-cp312-cp312-win32.whl", hash = "sha256:e8a8545cbd58bd29494ffe81e3cb35f8a29332a8e495c42bec334145ce8cd65b", size = 2446090, upload-time = "2026-10-14T13:18:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3a/625a6dd0173e88dbea1826405b4bcbfa06c6ca095310ed720caba36b2e43/fonttools-4.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bfab2f5d1d255dec82f4bd082a1c10e77df808e42210890f50a9c30bf91570e", size = 2497985, upload-time = "2026-10-14T13:18:55.245Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/cd473e0a48427003733e92bc3e8077081ba537eb33f7c658f2b7bef63776/fonttools-4.67.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8239e2ca24878715a19f061d065b5721e87da81d145e48b3418f771a469b5a24", size = 3106141, upload-time = "2026-10-14T13:18:57.238Z" }, + { url = "https://files.pythonhosted.org/packages/ef/36/04d74f0c71d93829657a703d680a54968253bbb5c93babc34378eae2087a/fonttools-4.67.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1be99c1f07fca59510d657ef3eae584b5273fa4e203aff2383b3520744e19536", size = 2598180, upload-time = "2026-10-14T13:18:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/ed/e6/b0cbdedb363a49043d704d8c7903543fdd317596409fb8ac2cb604c1e73c/fonttools-4.67.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad8b4f7c754a627e91908fa1a1ccc90b489cd2810c0ba16acd26ea2ff5273db7", size = 5400755, upload-time = "2026-10-14T13:19:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/a8/26/939ae9874dd44116f2ecf61cb0caf029e3004ec1ed311a86389dee3450be/fonttools-4.67.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:50c41e30aa2e0130b80d1a58ac0f3ea7c02a854a70dbea1ff8d88e0ce524806f", size = 5382979, upload-time = "2026-10-14T13:19:03.726Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d1/35a0a34ab74609d2e8dc7a1f45f6386c81942868fc4fdf8e873878f392fd/fonttools-4.67.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0781fe22583529e1e98bb8a3a33040632e202a4c427ed7e65412c41a21b8ebcb", size = 5343745, upload-time = "2026-10-14T13:19:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/293577941809c3ec5a7f0870c01b3729c682467a858b8978a5c3ea54c226/fonttools-4.67.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36f0fee56227b909c9d1392f17b23803616f1f04efbe020c176d9945cabc0be5", size = 5501573, upload-time = "2026-10-14T13:19:08.241Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3c/4e25460f37840c51b3983a7a83ceef7a1efa9ea588aca6f0e3a852f4b120/fonttools-4.67.0-cp313-cp313-win32.whl", hash = "sha256:48696b630069e29b8aa5ea8b034e4f651a2e112073938ec16bd536dadde1debf", size = 2444457, upload-time = "2026-10-14T13:19:10.463Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/39e9461211309965514642c005a8d51e866a1092f69f5f693b16de9c5395/fonttools-4.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:7343cd0ef70edf8be7f4913cb9b55b992fb4e04055b47dcfecddcc2eb045a9d2", size = 2496147, upload-time = "2026-10-14T13:19:12.588Z" }, + { url = "https://files.pythonhosted.org/packages/25/5b/c418f48918e40ef8c3f0f555567fe013c0c8058a8afa8040d6baeec80683/fonttools-4.67.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:846982e89b1861d6c9d7fcd6567aec3fa5a10ad313e7f2076045fcd339cfbd8e", size = 3110155, upload-time = "2026-10-14T13:19:14.877Z" }, + { url = "https://files.pythonhosted.org/packages/30/18/49013c643c3d56fce1b7e909ef7c01c36a5bd906dfb58571c9dcdaa4dc38/fonttools-4.67.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:952eb091689545d86d16e40f719ed7bb086dd810a07dcc9ea2ca0a81004810a3", size = 2599657, upload-time = "2026-10-14T13:19:16.93Z" }, + { url = "https://files.pythonhosted.org/packages/1f/2c/b7f33fa3bd1e4afdf9bf93b760f22486350eda487ce76c47f5931f868957/fonttools-4.67.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b5d511ea012dce7bd6df12b279b7d7a5b01b019865717d03ae679f4b944fa5", size = 5385200, upload-time = "2026-10-14T13:19:18.868Z" }, + { url = "https://files.pythonhosted.org/packages/79/fe/fef04b2cc2930edba11095f9e9b5c2797f8594fc54316195cc39d3c3bc63/fonttools-4.67.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:916836845e4b1c1447bb61390ffb3cb5f2940fd9f5d6de4685539a81806c7764", size = 5328754, upload-time = "2026-10-14T13:19:21.179Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c6/41cd4f6137f61dd059cc0609b73d9556091ecfcc8cb4d3cc543129c8ec24/fonttools-4.67.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:775364ac079e2ea7a2eedb5f9172c57b059d638ff79e2bf8d4257e5805713f32", size = 5327159, upload-time = "2026-10-14T13:19:23.153Z" }, + { url = "https://files.pythonhosted.org/packages/53/5c/08abd0a6d5c36624411e1b934745b4689d4309b03e98d8cf49f9469c63b6/fonttools-4.67.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b3ddf350e74508102b33dc6b32984b6dd751359a7c57732bcd39f9d7cb37d71e", size = 5460332, upload-time = "2026-10-14T13:19:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/b5/0f/59e835023817fe3932653067fde74960a0800fb95535375d8206aa9ecd68/fonttools-4.67.0-cp314-cp314-win32.whl", hash = "sha256:72d6d316dffc92eadb771f697f289ea7b60f689580931328905a267bd170f93b", size = 2448714, upload-time = "2026-10-14T13:19:27.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d3/5230265a5ff16aead01ce1a432a6b5bbdabe086f433988f41a1395e6dff8/fonttools-4.67.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e2c1586b5b6588a47d02e2588170eefdc996b708f2659c44dbe169bd6fcacb5", size = 2500790, upload-time = "2026-10-14T13:19:29.906Z" }, + { url = "https://files.pythonhosted.org/packages/b3/38/d899d7bbbe04d27dd509ac6b8f58f73fc240bb1dfe0ada9a9d33ad3bf9f2/fonttools-4.67.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:84a3aed005de106fb1794372dace82eca50859d52ae26da4bb6c602480a41250", size = 3183808, upload-time = "2026-10-14T13:19:32.015Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f6/4f465a62972e383b3d82205841b93f625a4e5ece6e5693c5be2a691ffe6d/fonttools-4.67.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:64e56d0d6a39780fee86955c758674538387b18f911ea904a4aae8f8e30fa26f", size = 2632826, upload-time = "2026-10-14T13:19:33.854Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/ce1ae8f8baa75feb2320caf6f74d2c228eba210a13b3e0895c0403e5e987/fonttools-4.67.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c21073cfe7129aaa070d94f575c1e2a880ae4aae1dcffd5352f174b96d27d16", size = 5557857, upload-time = "2026-10-14T13:19:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/495fe0a6bb8625e693c1417e178aeac42a11aa47e79efd7611c7bc5fb81e/fonttools-4.67.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:720bcf27727193b0fe1883c2e036dc88e37047916e977f5c3daf6ee4316e9656", size = 5364450, upload-time = "2026-10-14T13:19:38.5Z" }, + { url = "https://files.pythonhosted.org/packages/19/9c/d9730d3dd32e39583d6db929d0867df02042539bb0ebc3ad3d92a52a6aaf/fonttools-4.67.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6c19a770a8d273371a37969003c143eaa629ab893c3db028af8b91d04c6f9a6d", size = 5425231, upload-time = "2026-10-14T13:19:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c6/d41c1163431828b0fa2172e867798e0c4517ac6606e774b9175e048fb666/fonttools-4.67.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:13d7507252c5a5d7941a5fa1be27d335c378ef07983ea2bb24988bf600eadd5e", size = 5460574, upload-time = "2026-10-14T13:19:43.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/af/14885b78b1c1ff7219f890b79a5a6f76608d907c171b40e43839de995f54/fonttools-4.67.0-cp314-cp314t-win32.whl", hash = "sha256:07a2f36b3263faadf5b7b548f62fd3cac401e490189c82b16f7139ac0df91cd4", size = 2482468, upload-time = "2026-10-14T13:19:45.91Z" }, + { url = "https://files.pythonhosted.org/packages/cf/33/3d660eb850d24a81b4097ed46a1352c4ac0e4c10025526fa115e1871fc64/fonttools-4.67.0-cp314-cp314t-win_amd64.whl", hash = "sha256:fd79e36c2968e9fc3e1b082f2ba7dc63ae88a161a3d8ceaa0746b906455f3617", size = 2532326, upload-time = "2026-10-14T13:19:48.023Z" }, + { url = "https://files.pythonhosted.org/packages/b2/74/ebff33b3c6dfe77d86a1b67b470c3d817f044910203880a1f4e92a08bec2/fonttools-4.67.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:89ad62d116f45bb45873bb92fd69c14a720ba591cba488044731954a5565e194", size = 3104541, upload-time = "2026-10-14T13:19:50.418Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7b3b786447cdda91f8cd06e44bf3b906e71825118f5cbb9b69c099415152/fonttools-4.67.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:1671e5f368b0c136ed9fb62fef26c7e425b4ebb0bb669a1cb7ba453f5bba580b", size = 2598216, upload-time = "2026-10-14T13:19:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c8/c0c08d8a76b2ed460bf8b63642d98445aa18179a14005cae617bfe9ec732/fonttools-4.67.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451077d2fc61a2a03f5dca54d84fbb01051ad781f48ea137eff35c775a4cb025", size = 5382399, upload-time = "2026-10-14T13:19:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/3c/db/66b5ef9985c7d69f7b3521ee965c3093b1802322fb6c16e8c3da608b747e/fonttools-4.67.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f200cd2cf046a5a0b03babe84ebf8bbc12187d5d57f50bc03f24be89e7c1605", size = 5345746, upload-time = "2026-10-14T13:19:56.472Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b0/77d22a73d5cfce9651909583ea3011c7ab26daf155b0eb21f7a3f02ac78a/fonttools-4.67.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bd3239e5709fd4c3343db67245ede46aece610d7f7ef61afb174718122479282", size = 5322591, upload-time = "2026-10-14T13:19:59.539Z" }, + { url = "https://files.pythonhosted.org/packages/97/b8/d3e7b799186fc3213a31d0cfa2c553c5d8eed0a7c7960dc3cf7c0d0497fa/fonttools-4.67.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b274ed3106b8086f237b7dbb1529c28142ba10ae40b9d285be0ae6a44b2946d0", size = 5470360, upload-time = "2026-10-14T13:20:01.876Z" }, + { url = "https://files.pythonhosted.org/packages/b7/89/c9799e81e6de16196d4781dbb81136d354eaef07136607917275a5fe958f/fonttools-4.67.0-cp315-cp315-win32.whl", hash = "sha256:fc6b6b03aa44f504c8734e62ccc3e4dcda9f4b8213a85aa80742e4d1cc9d96ef", size = 2447522, upload-time = "2026-10-14T13:20:04.197Z" }, + { url = "https://files.pythonhosted.org/packages/79/48/40f5591bd0e198d34ee3e25710e730c824750b3c822fc0a65b08e193de80/fonttools-4.67.0-cp315-cp315-win_amd64.whl", hash = "sha256:592d8f72024dea0408739a92599e4f839b960e1e887b25adc76dc87271fdac76", size = 2499770, upload-time = "2026-10-14T13:20:06.54Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5c/f98ee788f76ffad100427c20abab3a6213b37c97575dc82e4ccfaaafbc55/fonttools-4.67.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9c38fece8156cbda31b42d49c4a187858056a35932b88233b6fb31eaca5cf67f", size = 3175346, upload-time = "2026-10-14T13:20:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b1/af3016813fd44c0ed32d37f3a12cb707efd99edd8205bd8b73aea1f0f542/fonttools-4.67.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3b34324deb3e09ad648039a0a86d945b83f23a44fe3da74a84e6ada71fe0b650", size = 2629083, upload-time = "2026-10-14T13:20:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/b5/bc/13b45dec208145da2c49c063b6ce73ddb2e6e3bd137ba3613562d686a013/fonttools-4.67.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a19f6d5e1a373f2e4a5bdb9452c8ba212dd9f1e43df2fff042b896e28084e4a", size = 5535751, upload-time = "2026-10-14T13:20:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8c/01f2f16066c802ad2cd6f3321c226240475b30ada91d69d493f7a40445a7/fonttools-4.67.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ccaa87b312219d02cf72a79f1eb2f3ce028882d6fd1b79336141005db83b84e", size = 5357413, upload-time = "2026-10-14T13:20:15.289Z" }, + { url = "https://files.pythonhosted.org/packages/84/e6/d6dff534e9cb8688ec7ecddc353609bca580efef9967334e2289f56bd9da/fonttools-4.67.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:38fc772182ebff3e2ebba7886460476eb65842b601ca0b9221a6a5826136396e", size = 5403722, upload-time = "2026-10-14T13:20:17.535Z" }, + { url = "https://files.pythonhosted.org/packages/39/c8/4de02224adea134666e6705b0137cd3df2df60a03ce100797b2b221a73dd/fonttools-4.67.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f672398385849ff79e7dd50c0a06efe110c8ba23d8890f9b45fbb922bc2f55f6", size = 5450104, upload-time = "2026-10-14T13:20:19.612Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e1/3a32904bac7c3460e23a86e9e1529b40d0969a69bd4edefa31e2d2f1bae7/fonttools-4.67.0-cp315-cp315t-win32.whl", hash = "sha256:77e0d4096a2ac60aebe43928b5382766df2d148577db8e8ff79b6a50879a6c06", size = 2479606, upload-time = "2026-10-14T13:20:21.996Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c5/8834cfb95383059addca24f591379d152f137689ff63766736c26b0f9b25/fonttools-4.67.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8c58a8a9ad447bead6f91e5f50b23c0e4988538cdbd9bf2f68952b39f5900a84", size = 2527993, upload-time = "2026-10-14T13:20:23.949Z" }, + { url = "https://files.pythonhosted.org/packages/3d/61/4161946319472aaa9b897bd18ad5108a5b10f5ebaa503d921a001ac4fff9/fonttools-4.67.0-py3-none-any.whl", hash = "sha256:4304f03ed7f4ba000a8dcc941ad854bfa52e2f3b6112b8f099b6f431cf98e701", size = 1213142, upload-time = "2026-10-14T13:20:26.258Z" }, +] + +[[package]] +name = "freetype-py" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/9c/61ba17f846b922c2d6d101cc886b0e8fb597c109cedfcb39b8c5d2304b54/freetype-py-2.5.1.zip", hash = "sha256:cfe2686a174d0dd3d71a9d8ee9bf6a2c23f5872385cf8ce9f24af83d076e2fbd", size = 851738, upload-time = "2024-08-29T18:32:26.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/a8/258dd138ebe60c79cd8cfaa6d021599208a33f0175a5e29b01f60c9ab2c7/freetype_py-2.5.1-py3-none-macosx_10_9_universal2.whl", hash = "sha256:d01ded2557694f06aa0413f3400c0c0b2b5ebcaabeef7aaf3d756be44f51e90b", size = 1747885, upload-time = "2024-08-29T18:32:17.604Z" }, + { url = "https://files.pythonhosted.org/packages/a2/93/280ad06dc944e40789b0a641492321a2792db82edda485369cbc59d14366/freetype_py-2.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d2f6b3d68496797da23204b3b9c4e77e67559c80390fc0dc8b3f454ae1cd819", size = 1051055, upload-time = "2024-08-29T18:32:19.153Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/853cad240ec63e21a37a512ee19c896b655ce1772d803a3dd80fccfe63fe/freetype_py-2.5.1-py3-none-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:289b443547e03a4f85302e3ac91376838e0d11636050166662a4f75e3087ed0b", size = 1043856, upload-time = "2024-08-29T18:32:20.565Z" }, + { url = "https://files.pythonhosted.org/packages/93/6f/fcc1789e42b8c6617c3112196d68e87bfe7d957d80812d3c24d639782dcb/freetype_py-2.5.1-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:cd3bfdbb7e1a84818cfbc8025fca3096f4f2afcd5d4641184bf0a3a2e6f97bbf", size = 1108180, upload-time = "2024-08-29T18:32:21.871Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1b/161d3a6244b8a820aef188e4397a750d4a8196316809576d015f26594296/freetype_py-2.5.1-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3c1aefc4f0d5b7425f014daccc5fdc7c6f914fb7d6a695cc684f1c09cd8c1660", size = 1106792, upload-time = "2024-08-29T18:32:23.134Z" }, + { url = "https://files.pythonhosted.org/packages/93/6e/bd7fbfacca077bc6f34f1a1109800a2c41ab50f4704d3a0507ba41009915/freetype_py-2.5.1-py3-none-win_amd64.whl", hash = "sha256:0b7f8e0342779f65ca13ef8bc103938366fecade23e6bb37cb671c2b8ad7f124", size = 814608, upload-time = "2024-08-29T18:32:24.648Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/cd/9be253869fc42e764de7f3dedd6969af7d44ff9c3375214a3442a6f3fc08/fsspec-2026.9.0.tar.gz", hash = "sha256:0f08147951c8cb31d844c3547d631053b127863b60be04cf06e121333ee0e2fe", size = 333545, upload-time = "2026-09-18T17:50:42.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/c0/a98505f18594f1bce828bb159cec0fcf9860562f1a2c85913409fc8f3d9e/fsspec-2026.9.0-py3-none-any.whl", hash = "sha256:8dd6e646e99ea382bd85f97a45e6b526a442d79423a7dc673f1e2756d05fcb5f", size = 221738, upload-time = "2026-09-18T17:50:41.341Z" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "griffelib" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/27/b55f1a5278918be765fb2fd8b20966bc72bbdd3f789f031937cceea7834a/griffelib-2.3.2.tar.gz", hash = "sha256:df00c7a0dee3d86268d76788997a1859272cb1fb7b865658e043d2c0c3d52e60", size = 234729, upload-time = "2026-10-06T09:54:37.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/e5/0ae74c83c1cab2c14daadf28bd0143eb14bb81503834af987b66badc7b61/griffelib-2.3.2-py3-none-any.whl", hash = "sha256:8e710afededd5607f95bf3c8ccc175ee306e7084459faf27b9f9f44ef2a7a274", size = 169420, upload-time = "2026-10-06T09:54:32.038Z" }, +] + +[[package]] +name = "grimp" +version = "3.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ad/2e/95736b7c984705ff5ee376f07c6e71bb80fcbb22c8fa3b8c1cfbfa919d5c/grimp-3.17.tar.gz", hash = "sha256:5161e03c6f518fe4da43b06c97ff49a1e224ecb0e4b9fd6ef48235373d1b7b33", size = 832923, upload-time = "2026-09-04T11:28:54.609Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/7f/99365747c992a97e775829e859bde28993a1771fa932a5b160e3e629769e/grimp-3.17-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:aa1b4d439d18372390a61926a3c33d5de2f5dff7ac7a71b8b5a665644ce81bc6", size = 2143022, upload-time = "2026-09-04T11:28:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/84eb297e570f0eceb916f3d11fc399de78309e837f4a1c5fcff36af5e800/grimp-3.17-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fe6c3c9f111e900a43a3657d3b1d35b920af6ff943bc417bca4ebcd6140e1e50", size = 2101124, upload-time = "2026-09-04T11:28:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3c/abc9de477962e071480cd7f2fa4fbd171e1f3f2d2ebf7aeb704138b28f94/grimp-3.17-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5834e34cbf5ef52877dd6f6a3bcda137d0f07d44c65084d63913931b21951b2f", size = 2283264, upload-time = "2026-09-04T11:26:59.045Z" }, + { url = "https://files.pythonhosted.org/packages/12/2c/07f824f3d3776265831d555b458380fa32214b3ff1534a11b2d22b292c78/grimp-3.17-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d97de5133ae1588230d9c9c9724526e48e62be79caf32a85b8ce970db8ce46de", size = 2223945, upload-time = "2026-09-04T11:27:08.711Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c0/bcb08d1a5446a5618f129f0789fc2ea38d643974db1094d7eb68adb09508/grimp-3.17-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c3cbb0a6c5e8bf38ecd2e6d051e9d0992f3cf4c65bb01d3ccf525f48586f456b", size = 2375571, upload-time = "2026-09-04T11:27:37.307Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f7/0a81e9cac29af4c88e73d7bf4d250bd01b04894b0bffa70ad5026846eaf3/grimp-3.17-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c423bcd7fd139553d184f194486e1a718e816b5898ff688c436456151eb70c61", size = 2633241, upload-time = "2026-09-04T11:27:17.639Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/c2d80fab1af8bf92ce86b18d30b9a7f1d2315e72e4205774590fb8d7ac55/grimp-3.17-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42ffb9e69af7bd09cab96786bc3f21716dced76d9daf6d553ef6eb4437b4cd7b", size = 2352338, upload-time = "2026-09-04T11:27:27.439Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/0e5aa9fc7b3d18620b36b68c2645b9cb12364e316bc827452e2447c90709/grimp-3.17-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c615cd6f08965831adb5383c9214054003eb2b5ee2f4c466c388a9b87835cb0e", size = 2288519, upload-time = "2026-09-04T11:27:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3b/d874409b8deacc419e847bff13e1706e7b45772cc2ad80fdf44d79f0d337/grimp-3.17-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f6cc4ba9510691dd56111894da83bca78e407037f8149f808012dd67f0b5272d", size = 2460716, upload-time = "2026-09-04T11:28:17.05Z" }, + { url = "https://files.pythonhosted.org/packages/c1/08/667c2af86cdb8ed210e42ec4dfb4d0c3dd0f9e08818dfba6435742a14f60/grimp-3.17-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd72e76528837b634057689179aac014e3995f41d142c2e9eff8bf46f82c96e5", size = 2499499, upload-time = "2026-09-04T11:28:26.764Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/5d93258181d6333ab01014884a2beca1ac39c25ddea3492b12364041eca8/grimp-3.17-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:58ce75a40a0817bf5725f7a67cb5cc38f76f23a028e624b1125959d06db76a36", size = 2533771, upload-time = "2026-09-04T11:28:36.204Z" }, + { url = "https://files.pythonhosted.org/packages/21/52/d82b1c772509c80e1f0988694e30fb4fc4888c983273c2249a82effc15b0/grimp-3.17-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5e4dc31f50d695091fef8013e562470cd759123bd06fad2ebb91251850ba97a4", size = 2543062, upload-time = "2026-09-04T11:28:46.493Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f6/fec538a49b3297c0aba0386971e478522ec4b2f8fc25d6575f9579e57a36/grimp-3.17-cp311-cp311-win32.whl", hash = "sha256:802ea10b9d9e7b422c83f1277286be4225402bb4d2ab31b17ab43f3e850639d4", size = 1875122, upload-time = "2026-09-04T11:29:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/27/a4/b1a2a1d7c53e6f0976bc33e3225b82e8d45ad32ec528ac3e251d94172bc4/grimp-3.17-cp311-cp311-win_amd64.whl", hash = "sha256:11533e4afad7a516a4c2a6b8b3b24383fba9901c8fa0072b33450603eedc0649", size = 2002060, upload-time = "2026-09-04T11:29:07.629Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a5/3fb3e39ba1a34231bcdf27922121f864307f6b9a2f8145f17e285749d092/grimp-3.17-cp311-cp311-win_arm64.whl", hash = "sha256:488c0a70118c3600dd344455f280ab8d8894583f76e4091de770b019d9e30cde", size = 1926777, upload-time = "2026-09-04T11:28:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/ba/e2e37adeac007a42b074c9156b3a155692fc5a1a32008fb8385546e5c92f/grimp-3.17-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7375c3aea01495839360947df321d26dcebd037ef34a641627456f23fd91c63e", size = 2146861, upload-time = "2026-09-04T11:28:10.923Z" }, + { url = "https://files.pythonhosted.org/packages/97/d0/3ac947eb15b7c6fe615e01cdfef8152e8b2509c6d5439f7799fae1ca024c/grimp-3.17-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c7e75804d1d7f11813082b30a0cd7cf84f553c55d7c1edc28cde23656f1d1a40", size = 2090629, upload-time = "2026-09-04T11:28:03.234Z" }, + { url = "https://files.pythonhosted.org/packages/89/df/53d3eb18c3197ae2bb6c8be118026870cb89b2c33f99c7157345bc5e0734/grimp-3.17-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62f61d14aedfc0df57db6f7bc826df0f77f3c7115a2a5525aa76531ce2cb9fa6", size = 2277265, upload-time = "2026-09-04T11:27:00.481Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/6615a81f83d8ee31c96b1d8fa9a5d12fceb82acc17a9e71b28768b8185b5/grimp-3.17-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76b1517920dd93df17f6cf0bb79a1ddf193993e87a60dec30bbe67a5139187f0", size = 2220699, upload-time = "2026-09-04T11:27:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/39/60/487bb910bb5092a392f8b23ae8c5fde6d766bfe02e2c7a064ae9a14da30a/grimp-3.17-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8d55d03f5dc704b18fa7da04e7eebcc3b16b998b1e18818fb750e291b2991900", size = 2371257, upload-time = "2026-09-04T11:27:38.482Z" }, + { url = "https://files.pythonhosted.org/packages/73/52/5a280d6a9154b1f13a0c74082581cb699825535fdc194ae9ebc5d42c2252/grimp-3.17-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:79b0be412bc19bf298290713e5a2b459fe2a518f921591046b85ba90425bff20", size = 2627111, upload-time = "2026-09-04T11:27:19.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5a/5ef678b3f89a887a9e045dbfb5505876678323f8cf5a26f8b98d9ace875a/grimp-3.17-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c03a30879a80950e268f03e81603b8c51dc141c944700a0a9d72b2d7484ab24", size = 2347083, upload-time = "2026-09-04T11:27:28.976Z" }, + { url = "https://files.pythonhosted.org/packages/1e/77/06e46d5682745570d16c051734a7edf7b3c045ad620440f635dbd86447c3/grimp-3.17-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecf26d99a35ab7b5966af56118bb9344d1fd1b972d3c45dd5012effc7a68c844", size = 2284554, upload-time = "2026-09-04T11:27:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/eb/22/c00c7073a0fce1f9494c564e12705b42a1c421a50515dcf7ffd8e1543024/grimp-3.17-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e205949a5fe6b83ac1ea28525bea2b4867f2b230a851e6dfcdfcfcee1e3dda09", size = 2455050, upload-time = "2026-09-04T11:28:18.529Z" }, + { url = "https://files.pythonhosted.org/packages/5b/23/f2b0bffa2a62a73ca8736b550161eab2b88f66e6a3c9f7def18310c2d087/grimp-3.17-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7e2b4d8aebae7142c401231043113910773f289a8d6f9d719273eb719f732b83", size = 2496303, upload-time = "2026-09-04T11:28:28.147Z" }, + { url = "https://files.pythonhosted.org/packages/02/54/b4dfc51d8c628f700512953c78656305a21ead767956a7fd7aa46f1021b1/grimp-3.17-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5abd95dc0a8f816d2816e847adb0ab09a7e3571fd0942ea31cf5248505862bac", size = 2528584, upload-time = "2026-09-04T11:28:37.526Z" }, + { url = "https://files.pythonhosted.org/packages/b5/60/73c088e1bee1a95a61cc3523abff149b0c211405834ca74996c76e6a3afa/grimp-3.17-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae6360f6b4f711abf97b8888c0269de3756ea484c829615e0c567c7bb1ce944", size = 2538538, upload-time = "2026-09-04T11:28:47.701Z" }, + { url = "https://files.pythonhosted.org/packages/9d/66/0ff96f79bb1c4ffff621f7b50babbea6151fbaad461327af961f1fa2e49e/grimp-3.17-cp312-cp312-win32.whl", hash = "sha256:b1ab648732d03fa2ec4d7619d934409399bf827009c4c50f429c8d0bdd0709fb", size = 1874539, upload-time = "2026-09-04T11:29:20.342Z" }, + { url = "https://files.pythonhosted.org/packages/a7/72/e2544f4ba4f42f7732bdc13d60c344fd9da30eb083eb8cba2e7c155d222e/grimp-3.17-cp312-cp312-win_amd64.whl", hash = "sha256:d10ae67a1ee0af6dee937e032f3acf707741d866a67f3c71e5ec6df3b758e5c0", size = 1999174, upload-time = "2026-09-04T11:29:09.19Z" }, + { url = "https://files.pythonhosted.org/packages/dd/9d/9b2da1ff2951e13b636673814ec7df699660f892f284bf2f3d53e991283c/grimp-3.17-cp312-cp312-win_arm64.whl", hash = "sha256:ff161c9aed2ca3db9bc592157a36914ae8440d8d8f3c6986843dec11523cd700", size = 1923198, upload-time = "2026-09-04T11:28:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/10/7a/b1c76982a239347bfccd2ae95b9f79d3390cd14170f62e7eb822a17d786d/grimp-3.17-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:eb8f09da4302f05fc92fe7564f27731c0b43789d8893ae5e6be5df2b01668923", size = 2145452, upload-time = "2026-09-04T11:28:12.121Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ab/2a1a9574b391c0ee62cb33a633c0d377dce1b7d19d04a7ac5d5e6872d395/grimp-3.17-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f11566bcf388db4c79f1c7f1051f6ef150f778306350bb7d86d0c9aab32d297", size = 2091654, upload-time = "2026-09-04T11:28:04.635Z" }, + { url = "https://files.pythonhosted.org/packages/e6/12/7dc086132ac10a2409f158f0b0d7cc528a2ebe0a4e95bca895afe7452fa6/grimp-3.17-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bfda3e2ebb4e51beec4a3cb09bb771440f3192163107d9320e6d9ce8fe064e64", size = 2277300, upload-time = "2026-09-04T11:27:01.844Z" }, + { url = "https://files.pythonhosted.org/packages/5b/62/2e16a54580fa76fb7e64e8e8e2ebb899c709a764cb29149a88eb03f2eea7/grimp-3.17-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0ab1ab71d807fc6f56d82f1ab24acf2f2e0e5000e37b5540af9449e28c6830c", size = 2220417, upload-time = "2026-09-04T11:27:11.418Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/e1b0ad0d0b3d9dff996bad306542f3cf000cb86c368bfe3ac6a8bb1e2e2f/grimp-3.17-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:187b092664e882d10b9f2c3da99251d0aeb99186d407ed5d20f52005832045f6", size = 2370754, upload-time = "2026-09-04T11:27:39.995Z" }, + { url = "https://files.pythonhosted.org/packages/36/0f/015e48ad8029f788eaa3df71b66859b2b5dc63abb8d7a2014a2b5a581040/grimp-3.17-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:75da770aeee97d26b202d7251fe7f6562e7f2793e348501b0a64ca5c50e881b5", size = 2628257, upload-time = "2026-09-04T11:27:20.491Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d3/66da78504a1731237ed8befd9c66fb91037a7b82816443ca772107acf8fe/grimp-3.17-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5cc64f891f71f538d3d032d6e7193f69c3a2e0ceac72c55114faee5aa161f9f", size = 2347569, upload-time = "2026-09-04T11:27:30.282Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a2/6474d008fa49cc650d626798cb3b6e3fa7b0daaad24884fd6bb4288f6154/grimp-3.17-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dbf2d93b0ba7f6c1efba9f5b3e07a3da0a157392c650316a5c1478b0ef7e503", size = 2284227, upload-time = "2026-09-04T11:27:52.462Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/91cd378c08494fd0130e5cc2e54d60d12f53b21850e0dbccf668b6e73d91/grimp-3.17-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a22b5dcbf55f1c348885e09cbe91e63baedf6ce0f89c60bb01a0475c7d0a90b", size = 2454655, upload-time = "2026-09-04T11:28:19.837Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d0/b582238247ce51857c7ac770c3be10203c992696f3dc9153f1a979c011dc/grimp-3.17-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:73aa9459a604554b1dc1618148312036679f3974bf8b0285588220307147060b", size = 2495852, upload-time = "2026-09-04T11:28:29.606Z" }, + { url = "https://files.pythonhosted.org/packages/8f/99/e0690b02be70f346aadfe2aa8bd7e496c773298ac58c0d0fa6376c1298b2/grimp-3.17-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c31660d30e79569faac05033637c1eef4595542f44ec2323af54e900030164a6", size = 2527887, upload-time = "2026-09-04T11:28:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/69/7a/33e41d9b08e63cf398d8194a9208b21788c76916b5ac6d90146e8e2ebf95/grimp-3.17-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8f395c5ab2cf554504f46680b9269b769939ab5d7ea58f93e5c341e55b1affbd", size = 2539000, upload-time = "2026-09-04T11:28:49.037Z" }, + { url = "https://files.pythonhosted.org/packages/69/f5/7af1dd74b9608a85274414fc423a7162844492b198ece3fe31f4e1f88237/grimp-3.17-cp313-cp313-win32.whl", hash = "sha256:34369739ed293accbb674494ae932351516b316e8e85d1d333d4c51796af846a", size = 1874244, upload-time = "2026-09-04T11:29:21.693Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/af497adddfcf11f9bd4ab273625a49022bf5ba4f5fc389589f4e6edc67b9/grimp-3.17-cp313-cp313-win_amd64.whl", hash = "sha256:d21b859c50418bdb48b403a84450650fa7aab3102f3212d00d3c21c76a982167", size = 1998225, upload-time = "2026-09-04T11:29:10.5Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2a/07fd537997281049af2390fc8641de5ae063ae4972cb6fcec85873674595/grimp-3.17-cp313-cp313-win_arm64.whl", hash = "sha256:206cd9f7c757b97934f48ba2b8ec6d649725fd6314d6b182dd327b19b4f35707", size = 1922071, upload-time = "2026-09-04T11:28:58.34Z" }, + { url = "https://files.pythonhosted.org/packages/39/e0/e11ea5aeb62534c9706e17952d142aa844ce5659e2d66b4b8ba9377407d6/grimp-3.17-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0100ff6f08067c8eb8a77dd1433af5e173aea8dbf6a6b76f5372a3b49f2e0a43", size = 2161696, upload-time = "2026-09-11T10:36:49.104Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d6/ddf82f4428d8bdc54ced0db244cbf5d8e5638490c3ed6a8b9f5db55dfa51/grimp-3.17-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:67ada40a5c1cf4321183c0b0cd0393658ccd04b845caed002ac81dc2fdef828e", size = 2107301, upload-time = "2026-09-11T10:36:46.459Z" }, + { url = "https://files.pythonhosted.org/packages/86/1a/1dd0d9d2751e8078547e1cb7131e13d9624a2ea60700d926b3d624c15825/grimp-3.17-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa385915a4c17948bfb25f77509cbbfb33795f321d1c5ca7cc602e34ed58e99", size = 2278433, upload-time = "2026-09-04T11:27:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/58/71/3768fd253e7455a83d668193055691c82d3a4d434047116c572a27d2bd39/grimp-3.17-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8322a435b5ee97ff3db823df36cbefea569faec505cc0ca66223c27b4b44cca6", size = 2221152, upload-time = "2026-09-04T11:27:12.642Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4f/907a130e87554d57be14c2a7cedeb818d0ef519064877297d19636735f6b/grimp-3.17-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb953c21beb6ac85a59b5b54128be7fdddd89396d34b3c5705cf59829a85b431", size = 2371546, upload-time = "2026-09-04T11:27:41.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/4f/a3bf534aa7c582a47d8cfefab5fccb50e6f4dcd327ed6a8552d344a04ffe/grimp-3.17-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c34b904c25359f98e3f20771256d62329ed17f1e26a254a4f5c74bacc1f3bd21", size = 2628467, upload-time = "2026-09-04T11:27:21.818Z" }, + { url = "https://files.pythonhosted.org/packages/32/d3/2b2ab7b3a4e2c17bb1e10ec37e7ee8eecce7f72b1bf6c8019dd439943884/grimp-3.17-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69ce8be301299f15ca99843e89991c240a231836cf45a11c6bfc54fb9fbeaa9", size = 2347520, upload-time = "2026-09-04T11:27:31.73Z" }, + { url = "https://files.pythonhosted.org/packages/72/da/6bc1f783a739afa51ffa2e235791487b5d09202501b25843ecae9833a3ff/grimp-3.17-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:566411d1ac97e1f3a3f00a13ae33aebb9abda9787d3d9914f62f73b589d84fe2", size = 2284007, upload-time = "2026-09-04T11:27:53.889Z" }, + { url = "https://files.pythonhosted.org/packages/63/06/a08f1b16aec11f32bbc86f60e24986659ab4cfab79388c8d4b79f6a6723e/grimp-3.17-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5f89948029f3c7339a20bab007977194d3dbdddb27edae5c74b948d8f13191c7", size = 2455301, upload-time = "2026-09-04T11:28:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/d55e9686f3797d1a2c6403d2ffec3020c67591c004304a414fe8d7a9755c/grimp-3.17-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5a895bc27105ada9b0e76a80bf9cc898409e5cc3689e73e64ca1cbd71dc87dce", size = 2497158, upload-time = "2026-09-04T11:28:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/7f/85/a2d2e70c4f272752c395cc35b3732d82ff92c55e229d7bbd6777fd9868ff/grimp-3.17-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e80b57a7d03f53e96eb0a5b0844340f99f17eede184042fb73e3ef010176ab35", size = 2528862, upload-time = "2026-09-04T11:28:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/cb/03/fc3a58d9b22b011c1c3377c1ecee127a5f642de9b20de32d3ad7213ab71a/grimp-3.17-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c0cac76283679fe9c1fd761e63deb9bbc7ba46b32988113ecef4b13742f31b9", size = 2538240, upload-time = "2026-09-04T11:28:50.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/13/d7666cf03c79c275020bad40ea1df8597af7877083ef5a84b5d8b566029a/grimp-3.17-cp314-cp314-win32.whl", hash = "sha256:c8982f4fa1276e059665947156740a55a4e8e6b5a4d7f40730a7511afb608762", size = 1874930, upload-time = "2026-09-04T11:29:23.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/ce/abb9ccb50972351461065ca72cd0f528837a2f398621147dfaf1013698f0/grimp-3.17-cp314-cp314-win_amd64.whl", hash = "sha256:0b97dc29bdca523932bbbcb425fc3df3547cf970396b293bfa960b910f3932d1", size = 1999405, upload-time = "2026-09-04T11:29:11.832Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1d/93c12ff4ce9ede9f91aa03df66128d223a4c824a13de6ab702c973fcae0d/grimp-3.17-cp314-cp314-win_arm64.whl", hash = "sha256:80b5b25b8d8ed6cc68dc39ae9e4c2fb64acd1a3e66434d9cf4d68c72e46d505d", size = 1923324, upload-time = "2026-09-04T11:29:00.004Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/f94dd90757a2195415736f0e2be667ac4cd375fe86d875d4298661751fce/grimp-3.17-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e15340ee11e34bfaaee496cff53f8287dc9f417a94e5bdf3d6b4204305d004cc", size = 2145199, upload-time = "2026-09-04T11:28:13.287Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/9d7cb22fc136ad6a61bff3b74cf0b31b13fdf94e224d46ff1edd9b5b8c86/grimp-3.17-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cbccf2a6cd3f89bfdfd683d9b000bd61f6936e16b6d4d6619da592c54f79f272", size = 2090761, upload-time = "2026-09-04T11:28:05.984Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6b/b70add79e9e315d111bb1643b44e1b9e1ded82888856e1ad68bc484cd635/grimp-3.17-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5332893955688759a9f7868c3c9e56b19bc1694ee251852d3daf54876b35c115", size = 2279005, upload-time = "2026-09-04T11:27:04.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/4c/f5f001e592f8e8820086604db98373ca129e2831facad81ec0d1820a749f/grimp-3.17-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:848a92d8a63a38d79c89ab1e68fb5429d90079f39c07533ffd45ce756e7f210f", size = 2220430, upload-time = "2026-09-04T11:27:13.814Z" }, + { url = "https://files.pythonhosted.org/packages/83/13/cc57724307c22060e193a1dc9bc99fa14d46584c534a3936319ec579c599/grimp-3.17-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c89cfe49f822a2cc9c42cc7b0f3975ecb1281970735ea31610bfa06a7e04c460", size = 2370318, upload-time = "2026-09-04T11:27:42.535Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/05d9964d4b47ffe0ef826bfe04b3f4b713fd623c90e424ff002cb0d8a917/grimp-3.17-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:541c9a2b7f1ad5d4f5d1597740e51058fba8ca0c1a225d6ccc6d11a8364fa563", size = 2629127, upload-time = "2026-09-04T11:27:23.18Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/186a3087623f1556eb8b9d02baa00300003bb84b7b35677e3fcec34691b6/grimp-3.17-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:66b4a13edb3fe63be6f7dbbc2576786f501af9d386a5a1f60b2938f270aef97c", size = 2348453, upload-time = "2026-09-04T11:27:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/e8/6c/a718328adc50529135510b515c5ff1d9f94ab26ab8df9057bb1ef5ab8993/grimp-3.17-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:160abad142e168545be8d4cc91b2a95b4565f7571fc56f7fced69ce3589e5111", size = 2287338, upload-time = "2026-09-04T11:27:55.16Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/bf67bc6749abee0a1e97f8f14ee2cb19ccc435b306e22bcaac6118d402a4/grimp-3.17-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cd76fb13a534209c1777c807092f51b1570854eb49948d65cd5e1d94d80e981d", size = 2455299, upload-time = "2026-09-04T11:28:22.505Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/6c954092306e21c93c744432c3d47491c3264d3cc4b465941de5164a1cb3/grimp-3.17-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1688e2dbe382b6644ecc3f55053e319de70034d9dfe0809d21d441a590c320e6", size = 2496813, upload-time = "2026-09-04T11:28:32.303Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/cf0aa245ace55dd2f6bdd3bb646ec50c294bdfe623fd51327bd27efaa585/grimp-3.17-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0433d177f327c31410f21845322282c832d887f1a42cbdfa176133d8422d84b2", size = 2527052, upload-time = "2026-09-04T11:28:41.869Z" }, + { url = "https://files.pythonhosted.org/packages/75/b8/90cd11e346f12fb5269ff050ae825083410d3bdb54d82a3b8e955e4f9d26/grimp-3.17-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:790d68157e034e7848257ef46b37550040c9e4692cea17372d3d9f87cef1d84b", size = 2541238, upload-time = "2026-09-04T11:28:51.786Z" }, + { url = "https://files.pythonhosted.org/packages/48/92/10cc37db6149030c8cfc80fa5287f739c1e9620c8a05d1ba8dfdfc0a6c4f/grimp-3.17-cp314-cp314t-win32.whl", hash = "sha256:37e6333ca54d4d80b5c4f2eae324774b223dbdc65994dc9bccda2985a9261dd8", size = 1872673, upload-time = "2026-09-04T11:29:24.514Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cc/b257d8da079a87e65d0f1b0e004fd50f31ba25e7136a3b36f896e0e338f8/grimp-3.17-cp314-cp314t-win_amd64.whl", hash = "sha256:91e4bbbed4d934369b83071380e1ad5f37b7f5c80b80ffa924627db8d66fea11", size = 2000197, upload-time = "2026-09-04T11:29:13.138Z" }, + { url = "https://files.pythonhosted.org/packages/29/d0/feebddf766448932f2f1e40324fe75cf50ce6d3a21bdf87fc0eae263218a/grimp-3.17-cp314-cp314t-win_arm64.whl", hash = "sha256:28ae2ff57a9ef535c2e7054dcb18baf0cdc2dee3982367512bff34eaecb85cf0", size = 1920856, upload-time = "2026-09-04T11:29:01.498Z" }, + { url = "https://files.pythonhosted.org/packages/83/39/bb07084f0b97da66b0aa38077bd656c4f831b4aa3f167b1c060f6a65ea5e/grimp-3.17-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe3e2285a2bd4e62ddb8825de37152d2dc842394d419786d3d5a803b07bb84eb", size = 2372594, upload-time = "2026-09-04T11:27:43.884Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a9/905a484659749f40ed797963147202cb74c3b68d8e90a7a600af55de47ee/grimp-3.17-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a377d5935ae9a62ccbb59e492e16624cec97cc7244c4a964b0c6f83940440626", size = 2284577, upload-time = "2026-09-04T11:27:56.428Z" }, + { url = "https://files.pythonhosted.org/packages/30/6a/40fb12b111c95b94db4930f35661a629532d9d12fb484db5b5ef1a6c3da3/grimp-3.17-cp315-cp315-win32.whl", hash = "sha256:65a1dc2741bef8a96ff3ff71252db8e99eac2a32e09d5e2b63597474706186ca", size = 1876437, upload-time = "2026-09-04T11:29:25.725Z" }, + { url = "https://files.pythonhosted.org/packages/71/98/79af8d51961611e13b3dabc7a3b356b27aa696e5549bb439f1d6002be31a/grimp-3.17-cp315-cp315-win_amd64.whl", hash = "sha256:d20fbe5aab8b013b351313f8eaa3a46f4ac57677e357cb1c3d1012c606b565b4", size = 1999947, upload-time = "2026-09-04T11:29:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/96/d6/e1922c4c7e001210f98e0cf89bd76fea8564f590be3e9ccfe99435cfcd59/grimp-3.17-cp315-cp315-win_arm64.whl", hash = "sha256:a0288dc0e8947dead5c02a246e16f3f5e5cb486348171bdf1289885a13de36c1", size = 1923774, upload-time = "2026-09-04T11:29:02.897Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/f4f84e44a9237350c41e7c77343498cb1ad843491c18f29d6811021c617d/grimp-3.17-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:1bf67cb05a80796be99d1bf87e12d4421de101950c5246a8220b6c839cbf27c2", size = 2145245, upload-time = "2026-09-04T11:28:14.537Z" }, + { url = "https://files.pythonhosted.org/packages/71/98/822287270c41360f66c4b5638e040afc6bbf11e9f04cf5005bbd5f3e4830/grimp-3.17-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d058fdb980475eb9e0143792d63757ddff86c5776e792866e8208cfe5e8413f9", size = 2090367, upload-time = "2026-09-04T11:28:07.277Z" }, + { url = "https://files.pythonhosted.org/packages/ac/76/32d303603157a0d98cb8bde8757e6ae8fa02d41f1d62cdf843fb2e7fe727/grimp-3.17-cp315-cp315t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92f1989ce9f9ce26b7d45381df164c62aff783add84cd7a4216a6ebba0f9f03a", size = 2371543, upload-time = "2026-09-04T11:27:45.108Z" }, + { url = "https://files.pythonhosted.org/packages/7b/68/4551e23ab9422e2ead806c1fec9f4b3acd21a267e43bfe8e8cd7f4640dce/grimp-3.17-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e275cee07826cf36db1914427e68afac87588f759e1dec676870234d4a250ae", size = 2288003, upload-time = "2026-09-04T11:27:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/79161f05c900f7f7f1b0acd22a730b1be9f96680dd44efeef90aa45e6998/grimp-3.17-cp315-cp315t-win32.whl", hash = "sha256:a7235ccffb0350647ae2b01969f552a9859fea7027032f6cfed110801269a439", size = 1873249, upload-time = "2026-09-04T11:29:27.094Z" }, + { url = "https://files.pythonhosted.org/packages/94/b7/6f9cdeba2c1263fb91b791ddd5e924d6c91c2485f4f16b56698802fdd540/grimp-3.17-cp315-cp315t-win_amd64.whl", hash = "sha256:473d0e0c016ca429b9ec17f34d276db454ba4d467e3c915a46279d86ab004716", size = 2000579, upload-time = "2026-09-04T11:29:15.961Z" }, + { url = "https://files.pythonhosted.org/packages/ac/16/8ae9028f046bf8f48cc13f6de2de40adbcbf38fa79a3a5f265753b248da0/grimp-3.17-cp315-cp315t-win_arm64.whl", hash = "sha256:478aa0b696e5dd79373943e75b998fbb4ef3d146f24fe3569fa9d106ce84991b", size = 1921829, upload-time = "2026-09-04T11:29:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/15/22/e89d58bc1c96ec118e371abca8c07d4c6ad53b263e9f00e552b93aa34797/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645f17b80f61e33969d5e8ab30c1fb1247b44d6372c64fc6b50703f438f18186", size = 2287565, upload-time = "2026-09-04T11:27:05.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/13/e1679bdcb44a2ae72300fb7358d2a9fe84de5b96d8d5ef4a605287f73613/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:047955683b4113ce265b00fa4704f92b93e248390768bb45f5968aaafff0c5be", size = 2225542, upload-time = "2026-09-04T11:27:15.167Z" }, + { url = "https://files.pythonhosted.org/packages/99/9d/b53c545f8bb34988d26a1f690ad18228d58fa0dfad29c0353193bf99fab6/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28e88c83f3772681e3aff138fd7865b551d436d25e30d85fbed05f92cf05fefd", size = 2380060, upload-time = "2026-09-04T11:27:46.477Z" }, + { url = "https://files.pythonhosted.org/packages/73/90/2d291b17529adb5fb3fc579dfeb0c7ec5e662e6a9d5eb77d3ea12300daf0/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8129c210bdc73547ec78d11b1dfa48bd74e9da62157b0458c0ed5fe698d6c403", size = 2631413, upload-time = "2026-09-04T11:27:24.742Z" }, + { url = "https://files.pythonhosted.org/packages/23/0d/a069e17c524d04f9a01d97ad8f2bacd3947af300e5d968dc6854278424b1/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:935beb0f0b920d3629b9da4fcc457f70e76a9fd159eefd7d2c57ab99e712d80e", size = 2351953, upload-time = "2026-09-04T11:27:34.314Z" }, + { url = "https://files.pythonhosted.org/packages/fe/af/8ff5b3ffeb062a5337a8e124a0909cb2866e6448858ffa14bef8cfb8e27d/grimp-3.17-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:788d5ecf1ab806e18b788952cb205e135e4da4ae00882f552dada144bc6f9b68", size = 2291463, upload-time = "2026-09-04T11:27:58.839Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b1/64de4ca73e8c9bd8eed7f6019f18e96f4f8e02417bdb1f9208a3b55662bb/grimp-3.17-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:ff7dcc4746fd8f8b8e8104db19cd15665c61c6bd56dc492e632ac336b07efd77", size = 2463481, upload-time = "2026-09-04T11:28:23.798Z" }, + { url = "https://files.pythonhosted.org/packages/79/7a/dd2c5ef860cecfbcee7cc6eb00c1a2185a8ce12227aa4015b189d7ee1796/grimp-3.17-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:8575b7a82983f138fd06ebcafce89797fce36f69b6381f79b05714b8b9fcf2fe", size = 2500644, upload-time = "2026-09-04T11:28:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f7/9f3c7f2bef0fdde9a4a3627cf2c9c79e0f6fc3f637d694b384e3fd0237ff/grimp-3.17-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a88e38dc9e5410a951fd97595816396e3e6f05d2c2a43665a777a9558ba5dde5", size = 2537284, upload-time = "2026-09-04T11:28:43.262Z" }, + { url = "https://files.pythonhosted.org/packages/16/d6/acf7dc5d08ecb24700071c6e807b396603148b98f111df412ea1502606c4/grimp-3.17-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:1334e8ba5d1b57dec70e8990ba4c8c829fc7e999ac4345da45348488dc84b814", size = 2545938, upload-time = "2026-09-04T11:28:53.196Z" }, +] + +[[package]] +name = "grpcio" +version = "1.84.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/b9/46146728b3f4a5c7e34c17d0ab724d58b5456b116e76dc77d3ef4e79b135/grpcio-1.84.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:4aaeceeb7fa7d824c322d1ec3208c8495c88478a927295553235435fc49043ad", size = 6454572, upload-time = "2026-09-14T06:57:14.651Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/5d668b4102637410d700153fd12d6a798e3ff8308bd9dcbaeae93f191060/grpcio-1.84.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:06619ba1515e5ee69fb2a514e95dd8be05ce74cb3928d5b34f87f87c86fe3c27", size = 12359529, upload-time = "2026-09-14T06:57:17.202Z" }, + { url = "https://files.pythonhosted.org/packages/18/2a/52e29c02047a493f15a78c0502bde4d3fab7c19c7813944d367cd501811c/grpcio-1.84.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:158c1c11cfb61b4849c3caf4d52de6f5ecd376e14446feb4a90dc95a90d616f5", size = 7029927, upload-time = "2026-09-14T06:57:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/0a/11/9962b313553647abb091943e0721e4a1662ecc63cdfe930abf00abcce47a/grpcio-1.84.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a9383401d9f116f98cacd4eba6c505a6edb80ba65badfc8e8ed8ae64983bcc44", size = 7782268, upload-time = "2026-09-14T06:57:22.381Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14a9413cb7d4b2e782b4f79c81a918610caedf55138ab5916f5fdd4b002f/grpcio-1.84.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd8ea8eb3817b226057cc1c0e7ec4b378dcda52043b972b6ff12b1152178967d", size = 7187959, upload-time = "2026-09-14T06:57:24.686Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3b/6cc8e6aed8f23be40f52af341e5d4595ec3ec8d7572271a692b5c1212178/grpcio-1.84.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:756ea5c2da00fa65c930284892d2a9706828704ca3ba40b4c51c4834eb39fcfd", size = 7737554, upload-time = "2026-09-14T06:57:27.5Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6f61002a01802ca9675e1b3599c9b0f9f3cf168ded94ebacc02199309f88/grpcio-1.84.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28d2609691da93051e998495108bbddd2a9f7a561253bae94828d81290f30c15", size = 8792681, upload-time = "2026-09-14T06:57:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/eb/84/8bec1ae7e6732a9b435a394ddfdfffde46c2620ae0109823f7cce1a54455/grpcio-1.84.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:27b8b36200a9fbee6e120246f4a8a41657549107ef19fb2c819c4b2fd524f39a", size = 8145493, upload-time = "2026-09-14T06:57:32.672Z" }, + { url = "https://files.pythonhosted.org/packages/59/84/c8c7bd210d657288f18af06522f150f61e81ea14fd3c7c135beed697c5fd/grpcio-1.84.0-cp311-cp311-win32.whl", hash = "sha256:465eef3d17e59ad22a556fc0138f7c7c799df426734344daec42c797d49fda99", size = 4495596, upload-time = "2026-09-14T06:57:34.799Z" }, + { url = "https://files.pythonhosted.org/packages/da/1e/da99356b3b573af357d059753a47fba54f1ca1a9c0e4deccd0210cb7f4ba/grpcio-1.84.0-cp311-cp311-win_amd64.whl", hash = "sha256:f9a456bdbed52a01c9ab8423bdebab04a5363c78676edc55ab9b58bd13bdf9e1", size = 5259900, upload-time = "2026-09-14T06:57:37.067Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/4c9a2e0e6b0aaf02781404cad2f79211f989f2c827cf672a4a48d1604d3e/grpcio-1.84.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:b5c6f20d657ae09ae4e30d9d3a21edd13f1219d58cc6f999b9d1bb63be9c1baa", size = 6415756, upload-time = "2026-09-14T06:57:39.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/131e7007bdee9acb77a8dbe8a16fa9fef75f88c1695242d8ee0993ac2d3d/grpcio-1.84.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:406583b4e8fb2282ebd392e12b963e601c1f82e07125a8c2cb5b144e7e024796", size = 12339195, upload-time = "2026-09-14T06:57:42.373Z" }, + { url = "https://files.pythonhosted.org/packages/db/d1/a7b7cda98fcab9b3d2916204a872d87371158a7a34e41768f524584fb64d/grpcio-1.84.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fbdbcd06986ede3ce584083b1dc2afe6808e8943e5cf50ad11183c03aceda25a", size = 6984468, upload-time = "2026-09-14T06:57:45.035Z" }, + { url = "https://files.pythonhosted.org/packages/19/81/c5be83e3ac9416f73c4c51fe1ea9c41a0c42fc3509e3505faa46f5046abe/grpcio-1.84.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:23e6e8e8a75cff88e0a793bfd3becea03a13e2763ae90c1ff573bc19ca5b429a", size = 7749432, upload-time = "2026-09-14T06:57:47.395Z" }, + { url = "https://files.pythonhosted.org/packages/a0/bf/258cd7c0a7ed92745dc93c31666d462d05b702807a689744bd49fb833bde/grpcio-1.84.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b44f0a0fc7bc6677d38cc80bca1a32814ce6c8f200fb8b3c1a61c9d77eaefbf3", size = 7156115, upload-time = "2026-09-14T06:57:49.657Z" }, + { url = "https://files.pythonhosted.org/packages/2b/4b/7f829418dbfcf91b875e55e2973f1059a95decb4f081313416317ef04ec1/grpcio-1.84.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:210e4c32f907045eb8158273e60c6ab69a3947697df6245dbda381f26c59485b", size = 7708010, upload-time = "2026-09-14T06:57:52.496Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/9932e2fec6a04205f8bf3f8f4d2020479dcdac88feb6f93822ed31bf0eba/grpcio-1.84.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a71d24f40b0cc6798feaa978c7411dc1135b7018e9fc0442db611c139bf58344", size = 8759980, upload-time = "2026-09-14T06:57:55.312Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5c/b67407c6dbc480dfc0715f6eccdb1061e7c88d85f9a330a241d357a538c5/grpcio-1.84.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f6c972474ce691aca74e58d17625450cef153dc4760364cadeb167983ea6d589", size = 8124904, upload-time = "2026-09-14T06:57:58.569Z" }, + { url = "https://files.pythonhosted.org/packages/02/37/2bfdae2df8dfcfc0df619b628e0c7153ce703adae827243f44720322ccc1/grpcio-1.84.0-cp312-cp312-win32.whl", hash = "sha256:0d532ade4486dad9b302ffa4d4683d67561051c26d17c4023322845e9fa10140", size = 4478915, upload-time = "2026-09-14T06:58:00.714Z" }, + { url = "https://files.pythonhosted.org/packages/85/2c/309268b7b39f6deb2342f634841e105623a0b67982e8b10ec516782ff1c6/grpcio-1.84.0-cp312-cp312-win_amd64.whl", hash = "sha256:49717e857899f4136d7657bf5aded61ac479110a075438290923a4d86af7cd02", size = 5253534, upload-time = "2026-09-14T06:58:03.336Z" }, + { url = "https://files.pythonhosted.org/packages/5d/51/40f99701adb01d4e5316a2aaf13838da1a24d5c879cd8c95156d7c364454/grpcio-1.84.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:209414080da8c20af94df1395b635da52dd57b5edc9e917e1deca0dc1c4bb55e", size = 6427619, upload-time = "2026-09-14T06:58:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/c5/4b/ed8e22a1237e6b2be6ef4f221d074a5b0e0dd8a0da8c944c04aea731f0eb/grpcio-1.84.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:e41c3993eee896c617dbd8a505085d28b6e84a0445ed9a1f40f95808473cf678", size = 12336549, upload-time = "2026-09-14T06:58:08.583Z" }, + { url = "https://files.pythonhosted.org/packages/d3/50/00165b05cd73f45996748ea67ce9e55d08936f2fea94a7fd8541cc2d0e54/grpcio-1.84.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fff5ef3fe1bba7d6147e5f19e01e5e122ac2c076486887ddcb8d42e663400fbe", size = 6989458, upload-time = "2026-09-14T06:58:11.884Z" }, + { url = "https://files.pythonhosted.org/packages/26/38/d0486230e684d916f97429a53041db88410e662a38f2a8d09e2d90375840/grpcio-1.84.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8c62888c3e49debf37ad9773e3c02f77b0c1e811f8fb0962f2b6c3bbab5b97a", size = 7757778, upload-time = "2026-09-14T06:58:14.849Z" }, + { url = "https://files.pythonhosted.org/packages/da/56/548a643decb059ca244499c675ae2c13a15f523ba94592c2774bd80a13c1/grpcio-1.84.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:986e9751d416d7a6eaa2fecdac38da63153d63a4b340ba7d624889c490451500", size = 7159572, upload-time = "2026-09-14T06:58:17.87Z" }, + { url = "https://files.pythonhosted.org/packages/db/f5/42caac81a79ec680f1f7a8eaf7ca90d2f93936ce0c3a073141ba96757f77/grpcio-1.84.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5933a052946873d01a42119a05420d669bdca436aeba2d1851988ccb12b421c0", size = 7710547, upload-time = "2026-09-14T06:58:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/57/a4/828ad990b2410fee0a55cc73aa1bf98eb5b911c54847374ef4f24b9e877b/grpcio-1.84.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e094dd21f077af8194923fc263cad872eaa1802bb0156fd7e5ae18e99cd86715", size = 8761519, upload-time = "2026-09-14T06:58:23.875Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/1f91af098919eaf5d80d5a61126ad9fae074e5190c25a3014ce1d8d0d890/grpcio-1.84.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08735e3d08d24ab3132cf87e2e5dea8746cabcc7d676c2b0b7362f195feef9d9", size = 8121424, upload-time = "2026-09-14T06:58:27.006Z" }, + { url = "https://files.pythonhosted.org/packages/8c/8f/77fd4a7a913b636785479922349c4cb98d94d05d15652e556b3ca0df6663/grpcio-1.84.0-cp313-cp313-win32.whl", hash = "sha256:70bb4ce8be0c5606bec259cbd7152374470396413b7863a658a08c849e6b29ff", size = 4477974, upload-time = "2026-09-14T06:58:29.528Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/1fa59ddbfc8898e5518d1447e46f771f387f0ed6132ad531395338e51a5c/grpcio-1.84.0-cp313-cp313-win_amd64.whl", hash = "sha256:b61692f0069b3eee2fc8a3a1b7f6c044df9e03fede6ce69b3ca832e1c39f26c5", size = 5255326, upload-time = "2026-09-14T06:58:31.781Z" }, + { url = "https://files.pythonhosted.org/packages/26/6f/e25ca89ca5b0b7b95464c907a5c21a77c0ac8c4ee1dca164c4dd8f153ddb/grpcio-1.84.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:026d757df86c5b7a41de8200b9a2cda454aaa5004cb0c7e3374c66eb82f61499", size = 6428207, upload-time = "2026-09-14T06:58:34.401Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b4/6b76b429f3f9b901cdbc306c81364d708bc957f847a05cbd1046cd2d05d8/grpcio-1.84.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3de427b05f244ba2c2a9bdc67e7a6731c8340811524ecc4435466549f8af1d17", size = 12342420, upload-time = "2026-09-14T06:58:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/af/64/ac86d638ba7f73bee0dccb608ba551d4f63adf75151f00d2c43e46d3979e/grpcio-1.84.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e90e3bdf7b5eac005fef631adae9cafde16f922def207b80a7c46b253c18ad20", size = 6998396, upload-time = "2026-09-14T06:58:40.535Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/fa12e9ec9d7ebf8cc3e81428fa9e1ca0d30d22d546ce2baa4c64bc917cbc/grpcio-1.84.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e88d304f094f4937bc27ec6a435e218a084168f11ec630c8d5d39b431d08d81d", size = 7757538, upload-time = "2026-09-14T06:58:43.297Z" }, + { url = "https://files.pythonhosted.org/packages/21/d7/94240c7fae121ff1f116dcf04a3b7ee0216a06832c704310363f72638d4c/grpcio-1.84.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:57dc36a5ab0e676f5f6e171de2917fd0aef73f32a9aaf23956bfe19997a30bd1", size = 7161480, upload-time = "2026-09-14T06:58:45.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/c9/7033e95d4b344969818b09185721c7608b47fc2498d97b5e4eec4995dbf3/grpcio-1.84.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5deda5b4bf62769eb98c119cca43d40e1231e34846b19db5cdea821d446a2253", size = 7720191, upload-time = "2026-09-14T06:58:48.308Z" }, + { url = "https://files.pythonhosted.org/packages/95/22/b45df2deba81d55069076859480bae7109c9eec02bce5515c799530cc2aa/grpcio-1.84.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9bab4cf571653a8afffb83ce21aa27b51dfe629b526b7b6adec35491fe1fc2ea", size = 8762792, upload-time = "2026-09-14T06:58:51.068Z" }, + { url = "https://files.pythonhosted.org/packages/de/c4/3e1c3d6155c16b8737cc31d5b477d6cf1fc7cdd10d58320cf0ec9b446f42/grpcio-1.84.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5559b492007dc09b4de9b95dab05f0b5e53547aad230cf07e46c7dd017a3be5", size = 8123299, upload-time = "2026-09-14T06:58:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/f4864de5b815e5ba18858771f99381a398fac14117f89ef5291ed43d3c4e/grpcio-1.84.0-cp314-cp314-win32.whl", hash = "sha256:2c024da73b296f040b8360e60bd73a659b230093684a438da0e1260f34cc724e", size = 4562560, upload-time = "2026-09-14T06:58:56.894Z" }, + { url = "https://files.pythonhosted.org/packages/44/03/640811d4d8c84f5e603995c5a9bab725223aa472cad9ca4286c3bbf1c3e3/grpcio-1.84.0-cp314-cp314-win_amd64.whl", hash = "sha256:800b7e00d92553313c0463c200087930aa78678ec1d528193aeb50906f55989b", size = 5394092, upload-time = "2026-09-14T06:58:59.61Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1a/9e3d2c9f005f680f03308fa894b1db91d4ab3f0fe65ff630c69561e91e95/grpcio-1.84.0-cp315-cp315-linux_armv7l.whl", hash = "sha256:47ecf0d9b81d981f07b61bd89eced9d2582f5eaacc3aaa36ad27f81aef70a27f", size = 6428252, upload-time = "2026-09-14T06:59:02.597Z" }, + { url = "https://files.pythonhosted.org/packages/77/34/0bc9f52ebf091311651eeab3a452fb557985604a3088cb5406f4d6df85d3/grpcio-1.84.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:61386101ecaa096b694d0dd278caf99a56aeec78440cc17e918eef0b50f2d567", size = 12359488, upload-time = "2026-09-14T06:59:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/c31052712f241cb6ecae9c226fabd519b7f8c64a7a40bac27e9ca0405b78/grpcio-1.84.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f6d178ba6dc8e82976c184b65fddde172d054c17237993a3e083efe4f134d55b", size = 7019339, upload-time = "2026-09-14T06:59:08.76Z" }, + { url = "https://files.pythonhosted.org/packages/55/b9/b9b33ea4f1eb4cad28833cade604febf357385b5ebb0c9c7562d020e167a/grpcio-1.84.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:15bb76489e337fc492685c9758e2fd4d4ab516b901ad830dc5a91987decf00be", size = 7107974, upload-time = "2026-09-14T06:59:11.568Z" }, + { url = "https://files.pythonhosted.org/packages/0e/9e/799d4c45db91bbdcd8c54b3982932dbcf3d059f7ce67dca3e8540faa1ece/grpcio-1.84.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82da34ae4f639c73ac46e521e00c0a49bf86f717b9fb1f405f133e98731e38dc", size = 7200036, upload-time = "2026-09-14T06:59:14.401Z" }, + { url = "https://files.pythonhosted.org/packages/45/dc/dcfdd13ada41aff9098f0c2c6f260eb7debbc88b84b7e5fcbd085165427d/grpcio-1.84.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b73836ba0e16fcbb57c31cf6cbc2907c8d8c790b83679df454b74bd15e0be04", size = 7742281, upload-time = "2026-09-14T06:59:17.348Z" }, + { url = "https://files.pythonhosted.org/packages/55/31/75eab2ec77b80804bc5e21cec99b57598e726fca6484cd3e8920a97639d5/grpcio-1.84.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:42959bd50dd660ffc3f2a9bec15a6da4f9aaa0dda555d59ff2d2e80b908456a8", size = 8113629, upload-time = "2026-09-14T06:59:20.584Z" }, + { url = "https://files.pythonhosted.org/packages/34/f0/fdcf6bdc1df9ca11679a1187bef8e6b81df31a2baae69497e17344f05ea3/grpcio-1.84.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:659728f20fc7a0933ed7b1945435e31014b97ab8a5a7edcbaa70da4794aeb191", size = 8152972, upload-time = "2026-09-14T06:59:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cf/6720e720bfa80fcb1ace873f66724eb3c8b03bba2fa078a30c12cab3212e/grpcio-1.84.0-cp315-cp315-win32.whl", hash = "sha256:edb6f87fc60ff438557291501b3e16c7a77c3b01a52d782cf276dccc7c5dd89c", size = 4561981, upload-time = "2026-09-14T06:59:27.275Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/69d8a709df225bc2e06e028e9465166b174c24b3da07cc72d9a5ddc63194/grpcio-1.84.0-cp315-cp315-win_amd64.whl", hash = "sha256:4119efa6519871719ad81f33bc95ab87857dcb1c5801f30a6e592f2c41164169", size = 5394757, upload-time = "2026-09-14T06:59:30.118Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "import-linter" +version = "2.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "grimp" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/81/1bb414f8c8bcd024499e91f112d317c36a86bc264da1fd43f9797fc2b7b0/import_linter-2.15.tar.gz", hash = "sha256:1da912bea5e172a82a3ce617b5543f75cf64dc0d8f4d9b46c5578b68ccb81590", size = 1405973, upload-time = "2026-09-04T14:47:56.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/31/4571c20652a4002ed9e500046386f3731a77e23d95a608ae8a87f1fbb2da/import_linter-2.15-py3-none-any.whl", hash = "sha256:9aaf16a88ac1e99d5a464cd7f66b6a05f7060bfa761162e0ed441773a267ed3b", size = 640512, upload-time = "2026-09-04T14:47:54.878Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "lz4" +version = "4.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/5b/6edcd23319d9e28b1bedf32768c3d1fd56eed8223960a2c47dacd2cec2af/lz4-4.4.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d6da84a26b3aa5da13a62e4b89ab36a396e9327de8cd48b436a3467077f8ccd4", size = 207391, upload-time = "2025-11-03T13:01:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/34/36/5f9b772e85b3d5769367a79973b8030afad0d6b724444083bad09becd66f/lz4-4.4.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61d0ee03e6c616f4a8b69987d03d514e8896c8b1b7cc7598ad029e5c6aedfd43", size = 207146, upload-time = "2025-11-03T13:01:37.928Z" }, + { url = "https://files.pythonhosted.org/packages/04/f4/f66da5647c0d72592081a37c8775feacc3d14d2625bbdaabd6307c274565/lz4-4.4.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:33dd86cea8375d8e5dd001e41f321d0a4b1eb7985f39be1b6a4f466cd480b8a7", size = 1292623, upload-time = "2025-11-03T13:01:39.341Z" }, + { url = "https://files.pythonhosted.org/packages/85/fc/5df0f17467cdda0cad464a9197a447027879197761b55faad7ca29c29a04/lz4-4.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:609a69c68e7cfcfa9d894dc06be13f2e00761485b62df4e2472f1b66f7b405fb", size = 1279982, upload-time = "2025-11-03T13:01:40.816Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/b55cb577aa148ed4e383e9700c36f70b651cd434e1c07568f0a86c9d5fbb/lz4-4.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75419bb1a559af00250b8f1360d508444e80ed4b26d9d40ec5b09fe7875cb989", size = 1368674, upload-time = "2025-11-03T13:01:42.118Z" }, + { url = "https://files.pythonhosted.org/packages/fb/31/e97e8c74c59ea479598e5c55cbe0b1334f03ee74ca97726e872944ed42df/lz4-4.4.5-cp311-cp311-win32.whl", hash = "sha256:12233624f1bc2cebc414f9efb3113a03e89acce3ab6f72035577bc61b270d24d", size = 88168, upload-time = "2025-11-03T13:01:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/18/47/715865a6c7071f417bef9b57c8644f29cb7a55b77742bd5d93a609274e7e/lz4-4.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:8a842ead8ca7c0ee2f396ca5d878c4c40439a527ebad2b996b0444f0074ed004", size = 99491, upload-time = "2025-11-03T13:01:44.167Z" }, + { url = "https://files.pythonhosted.org/packages/14/e7/ac120c2ca8caec5c945e6356ada2aa5cfabd83a01e3170f264a5c42c8231/lz4-4.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:83bc23ef65b6ae44f3287c38cbf82c269e2e96a26e560aa551735883388dcc4b", size = 91271, upload-time = "2025-11-03T13:01:45.016Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" }, + { url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" }, + { url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" }, + { url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" }, + { url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" }, + { url = "https://files.pythonhosted.org/packages/2f/46/08fd8ef19b782f301d56a9ccfd7dafec5fd4fc1a9f017cf22a1accb585d7/lz4-4.4.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6bb05416444fafea170b07181bc70640975ecc2a8c92b3b658c554119519716c", size = 207171, upload-time = "2025-11-03T13:01:56.595Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3f/ea3334e59de30871d773963997ecdba96c4584c5f8007fd83cfc8f1ee935/lz4-4.4.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b424df1076e40d4e884cfcc4c77d815368b7fb9ebcd7e634f937725cd9a8a72a", size = 207163, upload-time = "2025-11-03T13:01:57.721Z" }, + { url = "https://files.pythonhosted.org/packages/41/7b/7b3a2a0feb998969f4793c650bb16eff5b06e80d1f7bff867feb332f2af2/lz4-4.4.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:216ca0c6c90719731c64f41cfbd6f27a736d7e50a10b70fad2a9c9b262ec923d", size = 1292136, upload-time = "2025-11-03T13:02:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/89/d1/f1d259352227bb1c185288dd694121ea303e43404aa77560b879c90e7073/lz4-4.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:533298d208b58b651662dd972f52d807d48915176e5b032fb4f8c3b6f5fe535c", size = 1279639, upload-time = "2025-11-03T13:02:01.649Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fb/ba9256c48266a09012ed1d9b0253b9aa4fe9cdff094f8febf5b26a4aa2a2/lz4-4.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:451039b609b9a88a934800b5fc6ee401c89ad9c175abf2f4d9f8b2e4ef1afc64", size = 1368257, upload-time = "2025-11-03T13:02:03.35Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6d/dee32a9430c8b0e01bbb4537573cabd00555827f1a0a42d4e24ca803935c/lz4-4.4.5-cp313-cp313-win32.whl", hash = "sha256:a5f197ffa6fc0e93207b0af71b302e0a2f6f29982e5de0fbda61606dd3a55832", size = 88191, upload-time = "2025-11-03T13:02:04.406Z" }, + { url = "https://files.pythonhosted.org/packages/18/e0/f06028aea741bbecb2a7e9648f4643235279a770c7ffaf70bd4860c73661/lz4-4.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:da68497f78953017deb20edff0dba95641cc86e7423dfadf7c0264e1ac60dc22", size = 99502, upload-time = "2025-11-03T13:02:05.886Z" }, + { url = "https://files.pythonhosted.org/packages/61/72/5bef44afb303e56078676b9f2486f13173a3c1e7f17eaac1793538174817/lz4-4.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:c1cfa663468a189dab510ab231aad030970593f997746d7a324d40104db0d0a9", size = 91285, upload-time = "2025-11-03T13:02:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/49/55/6a5c2952971af73f15ed4ebfdd69774b454bd0dc905b289082ca8664fba1/lz4-4.4.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67531da3b62f49c939e09d56492baf397175ff39926d0bd5bd2d191ac2bff95f", size = 207348, upload-time = "2025-11-03T13:02:08.117Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d7/fd62cbdbdccc35341e83aabdb3f6d5c19be2687d0a4eaf6457ddf53bba64/lz4-4.4.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a1acbbba9edbcbb982bc2cac5e7108f0f553aebac1040fbec67a011a45afa1ba", size = 207340, upload-time = "2025-11-03T13:02:09.152Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/225ffadaacb4b0e0eb5fd263541edd938f16cd21fe1eae3cd6d5b6a259dc/lz4-4.4.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a482eecc0b7829c89b498fda883dbd50e98153a116de612ee7c111c8bcf82d1d", size = 1293398, upload-time = "2025-11-03T13:02:10.272Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/2ce59ba4a21ea5dc43460cba6f34584e187328019abc0e66698f2b66c881/lz4-4.4.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e099ddfaa88f59dd8d36c8a3c66bd982b4984edf127eb18e30bb49bdba68ce67", size = 1281209, upload-time = "2025-11-03T13:02:12.091Z" }, + { url = "https://files.pythonhosted.org/packages/80/4f/4d946bd1624ec229b386a3bc8e7a85fa9a963d67d0a62043f0af0978d3da/lz4-4.4.5-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2af2897333b421360fdcce895c6f6281dc3fab018d19d341cf64d043fc8d90d", size = 1369406, upload-time = "2025-11-03T13:02:13.683Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/d429ba4720a9064722698b4b754fb93e42e625f1318b8fe834086c7c783b/lz4-4.4.5-cp313-cp313t-win32.whl", hash = "sha256:66c5de72bf4988e1b284ebdd6524c4bead2c507a2d7f172201572bac6f593901", size = 88325, upload-time = "2025-11-03T13:02:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/7ba10c9b97c06af6c8f7032ec942ff127558863df52d866019ce9d2425cf/lz4-4.4.5-cp313-cp313t-win_amd64.whl", hash = "sha256:cdd4bdcbaf35056086d910d219106f6a04e1ab0daa40ec0eeef1626c27d0fddb", size = 99643, upload-time = "2025-11-03T13:02:15.978Z" }, + { url = "https://files.pythonhosted.org/packages/77/4d/a175459fb29f909e13e57c8f475181ad8085d8d7869bd8ad99033e3ee5fa/lz4-4.4.5-cp313-cp313t-win_arm64.whl", hash = "sha256:28ccaeb7c5222454cd5f60fcd152564205bcb801bd80e125949d2dfbadc76bbd", size = 91504, upload-time = "2025-11-03T13:02:17.313Z" }, + { url = "https://files.pythonhosted.org/packages/63/9c/70bdbdb9f54053a308b200b4678afd13efd0eafb6ddcbb7f00077213c2e5/lz4-4.4.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c216b6d5275fc060c6280936bb3bb0e0be6126afb08abccde27eed23dead135f", size = 207586, upload-time = "2025-11-03T13:02:18.263Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cb/bfead8f437741ce51e14b3c7d404e3a1f6b409c440bad9b8f3945d4c40a7/lz4-4.4.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8e71b14938082ebaf78144f3b3917ac715f72d14c076f384a4c062df96f9df6", size = 207161, upload-time = "2025-11-03T13:02:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/e7/18/b192b2ce465dfbeabc4fc957ece7a1d34aded0d95a588862f1c8a86ac448/lz4-4.4.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b5e6abca8df9f9bdc5c3085f33ff32cdc86ed04c65e0355506d46a5ac19b6e9", size = 1292415, upload-time = "2025-11-03T13:02:20.829Z" }, + { url = "https://files.pythonhosted.org/packages/67/79/a4e91872ab60f5e89bfad3e996ea7dc74a30f27253faf95865771225ccba/lz4-4.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b84a42da86e8ad8537aabef062e7f661f4a877d1c74d65606c49d835d36d668", size = 1279920, upload-time = "2025-11-03T13:02:22.013Z" }, + { url = "https://files.pythonhosted.org/packages/f1/01/d52c7b11eaa286d49dae619c0eec4aabc0bf3cda7a7467eb77c62c4471f3/lz4-4.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bba042ec5a61fa77c7e380351a61cb768277801240249841defd2ff0a10742f", size = 1368661, upload-time = "2025-11-03T13:02:23.208Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/137ddeea14c2cb86864838277b2607d09f8253f152156a07f84e11768a28/lz4-4.4.5-cp314-cp314-win32.whl", hash = "sha256:bd85d118316b53ed73956435bee1997bd06cc66dd2fa74073e3b1322bd520a67", size = 90139, upload-time = "2025-11-03T13:02:24.301Z" }, + { url = "https://files.pythonhosted.org/packages/18/2c/8332080fd293f8337779a440b3a143f85e374311705d243439a3349b81ad/lz4-4.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:92159782a4502858a21e0079d77cdcaade23e8a5d252ddf46b0652604300d7be", size = 101497, upload-time = "2025-11-03T13:02:25.187Z" }, + { url = "https://files.pythonhosted.org/packages/ca/28/2635a8141c9a4f4bc23f5135a92bbcf48d928d8ca094088c962df1879d64/lz4-4.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:d994b87abaa7a88ceb7a37c90f547b8284ff9da694e6afcfaa8568d739faf3f7", size = 93812, upload-time = "2025-11-03T13:02:26.133Z" }, +] + +[[package]] +name = "markdown" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/d4/f3f4b6ed70b7c7608fa026ff3bbe59ace9b1ebca43d8ae4886c87c95e81d/markdown-3.11.1.tar.gz", hash = "sha256:496f4f80f9ebd3395a04c8ec9595c40bbe8ec19e9c67d21fe071a1643e876606", size = 492927, upload-time = "2026-10-13T19:29:13.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/e6/1c7b7a48aa3f2c2a5d3c71a6c9c90a6c8c2903e5c73663b5f5e38f87257f/markdown-3.11.1-py3-none-any.whl", hash = "sha256:f1fa378ba5d682900c9ecb55ccceacca936016dda7c3b27097e8ae03ff78feb5", size = 116774, upload-time = "2026-10-13T19:29:12.066Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588, upload-time = "2026-02-10T15:23:55.105Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530, upload-time = "2026-02-10T15:23:53.817Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/e8/445b19834bf9559ebbbaa76897e965665b83d2861461eed8d8de4d122057/mkdocstrings-1.0.7.tar.gz", hash = "sha256:0951b147f352118a45a3db85f0f3b660833d34cb5120a2751315b8d08dafd159", size = 103739, upload-time = "2026-10-10T13:50:59.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/c3/9062531735ff1fd27f3a79c353c4cf4d822eeffd062fd90b2c553a96f4bb/mkdocstrings-1.0.7-py3-none-any.whl", hash = "sha256:9563828667eeb35256f87a6b61fe857676ac628ec1abbce9aea9839a502c9cb1", size = 41597, upload-time = "2026-10-10T13:50:58.43Z" }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006, upload-time = "2026-09-22T13:43:27.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698, upload-time = "2026-09-22T13:43:26.345Z" }, +] + +[[package]] +name = "nnnotes" +source = { editable = "." } +dependencies = [ + { name = "brotli" }, + { name = "grpcio" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pillow" }, + { name = "pycryptodome" }, + { name = "unitypy" }, +] + +[package.optional-dependencies] +deck = [ + { name = "ournotes-sim" }, +] +docs = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] +fonts = [ + { name = "fonttools" }, + { name = "freetype-py" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +remote = [ + { name = "boto3" }, +] +test = [ + { name = "import-linter" }, + { name = "jsonschema" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "boto3", marker = "extra == 'remote'", specifier = ">=1.35" }, + { name = "brotli", specifier = ">=1.1" }, + { name = "fonttools", marker = "extra == 'fonts'", specifier = ">=4.50" }, + { name = "freetype-py", marker = "extra == 'fonts'", specifier = ">=2.5" }, + { name = "grpcio", specifier = ">=1.49.1" }, + { name = "import-linter", marker = "extra == 'test'", specifier = ">=2.1" }, + { name = "jsonschema", marker = "extra == 'test'", specifier = ">=4.18" }, + { name = "mkdocs", marker = "extra == 'docs'", specifier = ">=1.6,<2" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5" }, + { name = "mkdocstrings", extras = ["python"], marker = "extra == 'docs'", specifier = ">=0.29" }, + { name = "numpy", specifier = ">=2.0" }, + { name = "ournotes-sim", marker = "extra == 'deck'", specifier = "==0.0.12" }, + { name = "pillow", specifier = ">=10.0" }, + { name = "pycryptodome", specifier = ">=3.20" }, + { name = "pyright", marker = "extra == 'test'", specifier = ">=1.1.414" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, + { name = "ruff", marker = "extra == 'test'", specifier = ">=0.15" }, + { name = "scipy", marker = "extra == 'fonts'", specifier = ">=1.13" }, + { name = "unitypy", specifier = ">=1.25" }, +] +provides-extras = ["deck", "docs", "fonts", "remote", "test"] + +[[package]] +name = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" }, + { url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" }, + { url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" }, + { url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" }, + { url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" }, + { url = "https://files.pythonhosted.org/packages/67/14/1c3ee0118a8fce08565a5d8482631608426a33af10a01077fada5dc7c119/numpy-2.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2377da2dd3ba2c1200956acbab2a358c83b8e1f8531191672d1cd6ad83250d53", size = 16997729, upload-time = "2026-10-10T20:03:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/83/8c/b0ea9477fb1f0d4484bbc5cba21678cc9969704d8d7f3f158d1db35f8e14/numpy-2.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7415db95818b39ec475a5eea54d9e3b6bc83e3912158e46da3438cdce399804d", size = 12009826, upload-time = "2026-10-10T20:03:11.946Z" }, + { url = "https://files.pythonhosted.org/packages/e2/84/6a3d75b3ba3dfe84ac0053450753d1e6d250a8bf80f66474cc46d1fb643f/numpy-2.5.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6d6a71b9d9a97c03633aa12565ef2825ffa036cc1d99cfd50dacf0f128af4fe2", size = 5445803, upload-time = "2026-10-10T20:03:14.329Z" }, + { url = "https://files.pythonhosted.org/packages/61/18/bb993f267ca20b376e07092a16793a5b31ed3138751e9ba480011a14d742/numpy-2.5.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d8200f16437b289a5bb927c6e184eccc3e8389bc0070fea4cd5b9e13c1757959", size = 6786220, upload-time = "2026-10-10T20:03:16.602Z" }, + { url = "https://files.pythonhosted.org/packages/db/b6/135bb0953b61dc21c6cafa14b424ae666944e4899cf140e00c2b322a1a45/numpy-2.5.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2e71b04c6cad90026e544501bbe0ab9290fa8a4d845e7e8c0d124fb429c988", size = 15689178, upload-time = "2026-10-10T20:03:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/24/3bd070f3269dc609d8f26b2643f62ef91bb415841c0b294805aaf7fe06da/numpy-2.5.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ffa07666f8da0eef81d149934a626d0d95fbd6838432a33e66245423a9062c0", size = 16718044, upload-time = "2026-10-10T20:03:21.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/9d15bd356b0a019c965312b1a3c6a727cac4cae5bc40045fbc12ce4cff9c/numpy-2.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2fa3328f784fc8277fc48026f6cad516f5c561c5d8e2e39b3c9e0c8f23223b34", size = 17048364, upload-time = "2026-10-10T20:03:24.468Z" }, + { url = "https://files.pythonhosted.org/packages/dc/fe/9d5b560db964f15871885f2250795d15945f8699e17ef90c0c2ff4c875b2/numpy-2.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b86966fbe4ad7de710422175572bcdc75fdedadfb54bc6fab7deabccddd7780b", size = 18474904, upload-time = "2026-10-10T20:03:27.895Z" }, + { url = "https://files.pythonhosted.org/packages/e9/98/d27552990f1bd611ef3e7466adadc78312ea2df63b83aad47fdc3d3ca8df/numpy-2.5.4-cp313-cp313-win32.whl", hash = "sha256:5258bc06526964be5face2fc6f756857a3f24f21ec3e72ca131337a75b165d6c", size = 6134537, upload-time = "2026-10-10T20:03:30.511Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/140a40398a66b4471211be1affdb6ed24c486d581bd28d07b7f2fcb69540/numpy-2.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:8b4d2fd2d34e5f8c9235ee787de5631a37a28402b15cb80814df973d2be54129", size = 12566113, upload-time = "2026-10-10T20:03:32.612Z" }, + { url = "https://files.pythonhosted.org/packages/34/52/01d205e5e8ccb27b2b0b141e801f22b830198c979111b0fa44771438d9a9/numpy-2.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:bc39ac66a7a9a3fbd6134fda43136b60ffde99c8f4501e64e0d2b24da137babf", size = 10519523, upload-time = "2026-10-10T20:03:35.163Z" }, + { url = "https://files.pythonhosted.org/packages/99/ba/005cb5edd580d2f84d7ca3206b92dc17d4388e56e6f87ffe8f2762f83139/numpy-2.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c668b2f0d651605b58892644b0e302c7157f7159544227758c896982ef384b18", size = 17005499, upload-time = "2026-10-10T20:03:37.961Z" }, + { url = "https://files.pythonhosted.org/packages/f3/49/fee7587c33ee35f7977f9051d7f2023d4e7246d62710c80f20c2361ea232/numpy-2.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ffa6ce09a1c6a08e9667dd9c97aa0b14184e8d18f2a14b78b2a2328c9147f076", size = 12019666, upload-time = "2026-10-10T20:03:40.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:956555e0603a4d38019ae6925711cb9dc43195c076a928accf7ea5d50bddfe53", size = 5455617, upload-time = "2026-10-10T20:03:43.138Z" }, + { url = "https://files.pythonhosted.org/packages/77/7f/dd85ce260a669a89be06842cf355d7353a33e6cfbc590fb8ebb947d88dc9/numpy-2.5.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:2c2c4afffdeb7920e445028dd71eb932cac3e704792e964bc2a232426d4f1255", size = 6791932, upload-time = "2026-10-10T20:03:44.874Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/34b0a2b0741386a63025a65a2c09caaaaaad6d0ca95b66cd65c30dd7fcb5/numpy-2.5.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4054173604cd8658796053f1f3bc0befb68ec1c0762c57fdad61e199256a8617", size = 15710899, upload-time = "2026-10-10T20:03:46.839Z" }, + { url = "https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d549420b8858885cea8838a727842249218b9c1da24dd517e25c9c7a948310a3", size = 16721710, upload-time = "2026-10-10T20:03:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/673fd1b8f4cd78eb6320e87ec4c90ac19c095644259e3749853a405c70f4/numpy-2.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:823874a507a84af050493b622affde94b6f7c3a0dc22cb2801381bc03b871c00", size = 17066182, upload-time = "2026-10-10T20:03:52.25Z" }, + { url = "https://files.pythonhosted.org/packages/f3/92/a77b5061b1b3e2643928c37976d79ee173e1b171ed158b7a3c61056b41bc/numpy-2.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4e263278bfb5ee6409db8aedbc4cc32973b1b82bc1e8d3c668551d04d83a7e37", size = 18480315, upload-time = "2026-10-10T20:03:55.39Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1d/1486ef3d3fb2279fd93c4c43c1bbbf1ca389a19816696684409f71babaab/numpy-2.5.4-cp314-cp314-win32.whl", hash = "sha256:cfd73180400042a7c532d30c5e287bdd03c59ff9ee1b4c0316af0539e29dfe23", size = 6185739, upload-time = "2026-10-10T20:03:58.186Z" }, + { url = "https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:2ca144f15135b6212a5c47b1e2aeca6e412f102f95a2d5d88d8aec77eb255de3", size = 12703552, upload-time = "2026-10-10T20:04:00.28Z" }, + { url = "https://files.pythonhosted.org/packages/2c/05/de709a982d7bbcd688a3fad71f002e9ff80c2db39e03ee726609b610f1d1/numpy-2.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:468397ba3c64427474706e5c9123fe266395496714dc684294eac75cd4930d1e", size = 10803901, upload-time = "2026-10-10T20:04:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/13/34/083570ada3bb2a30fbe5d77c8c6fef9141144a15d33e6f793a67e9749ab8/numpy-2.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1ef3aa6d7e29bb13677323114280b05acc57607fa2300e66432d665d5418a162", size = 12138695, upload-time = "2026-10-10T20:04:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/94/06/1f9c24db48eef0c2d1207e3b11fffb0478e39dfd8c1e1be7476936885eed/numpy-2.5.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:98b053943e5a0474ec0da309d2cb9d3f18ea57f8a2067c2ab7b5f763d1068380", size = 5574615, upload-time = "2026-10-10T20:04:07.316Z" }, + { url = "https://files.pythonhosted.org/packages/da/0f/593fba2e1560e949123bc7d2fc48b5893d56e58cd4bd5a273d2fbf60b220/numpy-2.5.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:b64a85f40e154983960a4167d4c1d57a50c7f109b3d3264a3a984154e90a8454", size = 6889383, upload-time = "2026-10-10T20:04:09.918Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9f/b799dfdce4e05e80ed4bc815c71ff343a11533b2c0ffc221cae8538cda63/numpy-2.5.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a813ed7719bf45463c51779e6a98d0385fe905e48447526938a4b8337333d551", size = 15753763, upload-time = "2026-10-10T20:04:12.278Z" }, + { url = "https://files.pythonhosted.org/packages/34/88/16c5f12f86f5ad2817c4d103205131fc6c8acb3d1878af05a1a4f23ec859/numpy-2.5.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b80cdf5cedba0e90d93fa5f9a333c4d65bd545cd669b71bb97ce2b703c9d73", size = 16757212, upload-time = "2026-10-10T20:04:14.799Z" }, + { url = "https://files.pythonhosted.org/packages/ff/4f/a1fe40e18a898e6a5089f4f0d891f0a493eb0574d5b34458f0fbe5aa3e5c/numpy-2.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2199ed071f460487c8db2c0e5c0b564494190edb4772fe80f9aad88b2604def5", size = 17116471, upload-time = "2026-10-10T20:04:17.58Z" }, + { url = "https://files.pythonhosted.org/packages/aa/46/e923a11c78e65c1722e7aaad817c06bd591324174b9d28ce5d31eee4d432/numpy-2.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:64f9c9878c1938476365e11ccfb6b770f3b9e5f045ccddc514235041e6959365", size = 18524063, upload-time = "2026-10-10T20:04:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/5a/fa/84ab064514440c1f64a1b21088f2c82756defdd05e07c75ab233899565b2/numpy-2.5.4-cp314-cp314t-win32.whl", hash = "sha256:64d1c8ac28a4077cf987e0a71a7a0ef7e2df70722f07f0baa42dbb7eb6938647", size = 6340926, upload-time = "2026-10-10T20:04:22.865Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6cd886876f435b10685db9b9f7eeb70356f99e052116f4e5f11c5792c714/numpy-2.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:067374eb538c34c745436365cf7b0112595c1d326f21ce4ff340f61230239fbb", size = 12901584, upload-time = "2026-10-10T20:04:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/38/1b/3c1684f6a06f7307f2335fca6e486cb162847fb97e91d65f8eb5cabad213/numpy-2.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:e94aef2c639da4a960ad0db8e06471208d8589974953d78b61d345b4eb99e394", size = 10891152, upload-time = "2026-10-10T20:04:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/08/f4/3224deff3af2bef6bc0b175369698d8cb348f3d91d9bb0286cd5c9eae9e0/numpy-2.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8dddfbee2e68d26d0d7d7d9cb247b1fd4409241cce32d815a11d97ec2cfde179", size = 17003231, upload-time = "2026-10-10T20:04:30.021Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/fee0b8c6d94b44b2fdfae74f6a4ad5a138739589a8aebaec28ce4e713ed5/numpy-2.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:81e3420b27048b65eb14c3acf0c174a8cb0e023277716110347d2dcb26026dad", size = 12018300, upload-time = "2026-10-10T20:04:32.519Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/d0b335a499a04b65f532c3f034346ef390f81299060f928492dabc1e0272/numpy-2.5.4-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:0b4724a19de67bea8cfc4970798efa78bcbbe2ac2613cfac16721a42d44de2a5", size = 5454250, upload-time = "2026-10-10T20:04:34.943Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/461b3783c03d668052e6a21b01b673db6ffcb7831fd32d9aa5368c1cd426/numpy-2.5.4-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:2132418bf8dd124a427ca9e6a1daf9ee1a87185344c95119ceae868b99466da1", size = 6789644, upload-time = "2026-10-10T20:04:37.258Z" }, + { url = "https://files.pythonhosted.org/packages/b3/02/5dad269b02166965a7b4ca14adaddd75dbee0de42435bfecf561b84ba5a6/numpy-2.5.4-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:325518d4245b9e331387702aa58c2ce1dc4cdcbb41dfb4ccd5dcbc7e08db1266", size = 15704353, upload-time = "2026-10-10T20:04:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/93/3a/01360c8036822ed9f7aa32189a77d1476567ec1e8e1383522389e4faac45/numpy-2.5.4-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56733449d2544178beaa4545cee357370440cf056c197f9c7bfb19dbfdd0e86d", size = 16718648, upload-time = "2026-10-10T20:04:42.383Z" }, + { url = "https://files.pythonhosted.org/packages/7d/5c/b863a2c093c4d6f21a597fcaf24ead0835c09ab16a8312d5a5a8868af683/numpy-2.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5ec3753760c1a6d8bb91200666e545c3a9728e6269dfb5d6ce02340996698aa3", size = 17059053, upload-time = "2026-10-10T20:04:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/0a/60/ced4f57f9a1258a0af74f17cb0b0c2700b5c67cd6678823c803b263e4df3/numpy-2.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b1185012870173de7ae33d370bd45b1cf5baee747ea4b97036b65f4e93016877", size = 18477406, upload-time = "2026-10-10T20:04:47.863Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bd/0ef22dafaafcc7d4bb3ca26b8d2afbd55dedad8eaba99a8c864e1997456f/numpy-2.5.4-cp315-cp315-win32.whl", hash = "sha256:298eca75243f2cbbfdb460560b9fb2a1792a33cf2ab4286efd43d92e8d3df508", size = 6185133, upload-time = "2026-10-10T20:04:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/d2651b155ecc608a77e6f4d15495c11f14f19bb98f8bf0c5b0d38f86dda1/numpy-2.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:332f3378fe077dd850e677ec01bdcc4f22368fb5d50ef10b2c79230b1bf5a592", size = 12703085, upload-time = "2026-10-10T20:04:52.63Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d2/45e404f8abb26fb9eda12b94012936873e827b1be76f2ee7890be128312e/numpy-2.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:d4cccbbc78717966f764cd3af4fb70276fa01fc7a2688af11c78901fa5c04f05", size = 10801451, upload-time = "2026-10-10T20:04:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/2ae14e09cfdb67dc187a342e15308a21c15bf4d2071f8079e6aee5fe56dc/numpy-2.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:950ea81d57ef070665581b6e1b5f6a029306423cd1739c5b95fe78aa30db6b9d", size = 17097121, upload-time = "2026-10-10T20:04:58.403Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cf/305ae624ef8a039414317224abe9ec9c2fe7ea3c2e1cf204d43ff6b2ffb9/numpy-2.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c05ede731b03fb1b7591faca9389ade3267d2bddf1ad8882bb3f2cc5e101694f", size = 12135439, upload-time = "2026-10-10T20:05:01.65Z" }, + { url = "https://files.pythonhosted.org/packages/a9/a8/f75c63813aef95827bb2c0d13b12803016853056e8792c280058cdbfe783/numpy-2.5.4-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:5fbf7141bbfd63aea22f435c9062a032b9ea0082fe9845dad7f021d3f1234e71", size = 5571451, upload-time = "2026-10-10T20:05:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0f/f17763f983868b5c49b4101ebd7e00760bd1769478a6bb6a8de6e085bbac/numpy-2.5.4-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:3573cd22564692a5b899ec344e5d5b9cc4576f2985b96f22af3564ed54f2710f", size = 6883356, upload-time = "2026-10-10T20:05:06.249Z" }, + { url = "https://files.pythonhosted.org/packages/67/a7/8af04c5a79e047996cfa38854dcfbececdd0343a7c933a46fdd03ef6f5da/numpy-2.5.4-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c109eac9cd439193678f69d70733c1108487546ca8eafc107b510ae10c1aecd", size = 15750991, upload-time = "2026-10-10T20:05:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/7a/648254290d0c504faa8f2d07aa206660c728802c781a6f3fc68ab7cb5d71/numpy-2.5.4-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80d6ef6e8620eb2c2b4c4caad50b5935d6db3cde2d51581b55dcc79e14016d1d", size = 16757675, upload-time = "2026-10-10T20:05:11.393Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/4a8c3cdb0c70400cfe4c5bec42d3099a5673802a95064614b33e07b82aa1/numpy-2.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:77045a4b175bbf5316ec08003880804336c78f92281a1b72222b274ea85ec5ac", size = 17113846, upload-time = "2026-10-10T20:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/619692bb67778702c0e9eb2d468568a7573f4e269386ea61aed01ee4e557/numpy-2.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f02a46e49cfb6c73bdb7aea1c0d3461dbae9aba613542b65f657cd3d17b9fab", size = 18522915, upload-time = "2026-10-10T20:05:17.33Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b5/4da41c328788f575838f97a098fe8ca691ebc6f6fd73ad4a262ee40b184d/numpy-2.5.4-cp315-cp315t-win32.whl", hash = "sha256:ad62a416ddcf863bf44bba76fbf6b53366ab0692e294f51cae4b5fbe0d246788", size = 6335804, upload-time = "2026-10-10T20:05:19.921Z" }, + { url = "https://files.pythonhosted.org/packages/98/94/6482ddfa3d312490cb9358f375bf2ad56427dbea8769187158e94d653753/numpy-2.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:38f47be9f74ab870d2633b5456ae519c43758a8d1fd05342f0ce4ecc034396ee", size = 12890095, upload-time = "2026-10-10T20:05:21.875Z" }, + { url = "https://files.pythonhosted.org/packages/48/7f/c2d1b436b6e7cfebac140c2579a298344b85f2991a2ce5c3615cefb29400/numpy-2.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:7a14a461d9340f1b46b8648578aed9cdb8b3b018a8fac6c1dde2c9192a01a87f", size = 10883718, upload-time = "2026-10-10T20:05:28.547Z" }, +] + +[[package]] +name = "ournotes-sim" +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/4e/eb46553be18aa30bccc427d0477bf3c03e1730b81e96d09b85fb3510047d/ournotes_sim-0.0.12-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:5873aa17abe6a3d0fcc8565e602be5a1e7274cb331187340cb394eabfa47ecae", size = 1189004, upload-time = "2026-10-18T13:44:37.821Z" }, + { url = "https://files.pythonhosted.org/packages/4e/45/7c5f591f84a96c38b96f5ea95c19d0a7d3b160ba519af563b559665db221/ournotes_sim-0.0.12-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:641c662585c8d0765e1c20459fa63c01cf67366010d32b24f1e8c766d28d21b5", size = 1278163, upload-time = "2026-10-18T13:44:39.92Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3c/315a33d4eaa27632a6639a48a05a53e6bd2a5a38aceb96a107f191827872/ournotes_sim-0.0.12-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c2c41866e383a402e304bee2a0b961e85280b107ad672cd3082f6bac469d62db", size = 1259142, upload-time = "2026-10-18T13:44:41.517Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a3/f1f3ed1c20be95b2772d7d33b19daad235eb05f6013ed583fe3d7239b56d/ournotes_sim-0.0.12-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2ab99e1e572a86b8603cf47441300232f78f1c4e53fc8e9e158e6056f04c5d18", size = 1340277, upload-time = "2026-10-18T13:44:43.355Z" }, + { url = "https://files.pythonhosted.org/packages/fa/52/fbc75f9c5ff98d5c59b7c7d9f3dbec3b0506b4d619115e980dad63a5992b/ournotes_sim-0.0.12-cp311-abi3-win_amd64.whl", hash = "sha256:812d4c72d95b4ab4aa9f31552696570b71a8c905a5a4e114e4f1dbdee0325381", size = 1239208, upload-time = "2026-10-18T13:44:45.222Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz", hash = "sha256:1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0", size = 61094, upload-time = "2026-10-11T02:05:24.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1", size = 32724, upload-time = "2026-10-11T02:05:22.776Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycryptodome" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/95/cf1a4d630500fc98cef83a58b1fd3bb75a74c4fe050f63cf40c052cbf2a2/pycryptodome-4.0.0.tar.gz", hash = "sha256:4ad4dd220fa22f99f5832847ccaea5bee39f140b8e4ea1a29aa77dc969c6490c", size = 1499510, upload-time = "2026-10-14T10:45:42.789Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/8c/0d3622c4b6fa203111df08f7ef1162c021302c2dd86b27c9b957a46449cc/pycryptodome-4.0.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:7b548ef0f3ae0625f30850cd6021c9a1228e783c56d20f072733ddc382a3f71d", size = 1357400, upload-time = "2026-10-14T10:44:48.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/35/d9dac7919689e2b90fad202c2c19d4beb59a56e8ad1b1627abae8df3b4ae/pycryptodome-4.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:828dd44762ae686e81af16d8b93cfe787cc72e51f5fe3b04fc18159b86c7cf4e", size = 1202109, upload-time = "2026-10-14T10:44:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/27/8faf6616815059c4f05dfa0591a020217b1e9fb68cf26703cf0577268dd9/pycryptodome-4.0.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f3ccebe7432ad15bfed0a65114d0b914aa1e25d2d69b5a972fb37cea55f77043", size = 1871373, upload-time = "2026-10-14T10:44:53.87Z" }, + { url = "https://files.pythonhosted.org/packages/fb/64/dc416c956f7d8d7aafe1da2dc8ccc812409896718d3674c9163ab3491ee7/pycryptodome-4.0.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2a9eeeaac8b604f3aa567a57a01be143c89809acece41782b62879e40d4cc2ea", size = 2531628, upload-time = "2026-10-14T10:44:57.044Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/a4f983e7d854f66b1f6f8c7a611a5073245962f2de130734d60383b909e3/pycryptodome-4.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:5aa9a6d543a6bd12a8bdb5f521345895cae77b9470e6a9dca180b466a23926e1", size = 1880738, upload-time = "2026-10-14T10:44:59.506Z" }, + { url = "https://files.pythonhosted.org/packages/69/2b/79b7270b3e98984aac4fa71a22809507762feca1ab25bc44d39a46d93b54/pycryptodome-4.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0f9851ce007a6a9376454c8b0ae257bda98823d1169496259b44c6615c429cb0", size = 2610834, upload-time = "2026-10-14T10:45:03.461Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/d3ff82098ce14fe039a997e6ab399f7a87978d1c4aa0b0849333869a1a7b/pycryptodome-4.0.0-cp315-cp315t-win32.whl", hash = "sha256:774448b19790e073d3fc38f86c0b36578faa75de2b5c7500f24401a2126486c1", size = 1179119, upload-time = "2026-10-14T10:45:05.164Z" }, + { url = "https://files.pythonhosted.org/packages/5b/1f/4177fa587673407b287ca2744c9debfb6fd425489efd7761bd6846fd330e/pycryptodome-4.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e0f2256d28d3d6fad2eb463629e2afd0fed6e2ffc6518da5f3de28f81e9798cf", size = 1225353, upload-time = "2026-10-14T10:45:07.019Z" }, + { url = "https://files.pythonhosted.org/packages/66/1a/eae61a4c6bedae0ecf370a7c2d49b88bc0f0c60676c91cf1b094288c69de/pycryptodome-4.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8cfde6bfd4a2d8c225fe7691375de2008568cae5458f374fb06ec1233fdc093f", size = 1173296, upload-time = "2026-10-14T10:45:08.781Z" }, + { url = "https://files.pythonhosted.org/packages/db/55/5fb4aab81c45b86edd40544e0b962a2aefca94c75c0bd3b68667861d4918/pycryptodome-4.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:70274777cdac701de642b31012b2264bf28cb435caaf17b795c96b6456886b62", size = 1358069, upload-time = "2026-10-14T10:45:10.344Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5e/e7558e37b08ff678197173a5899c7700e63432f53dfadd9f40460e1fab52/pycryptodome-4.0.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b8a7461b38e17c959172b3681b01542fbc8cf575ecb241306e4d87441f6824ff", size = 1200030, upload-time = "2026-10-14T10:45:12.254Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/b19f26d94ce59fa3cf119091aa951174087cf2fc2cfda5cc2e70a9cd562e/pycryptodome-4.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a47c2c401d1343f66ed22e05f52c577375e727afe275ab9477c13069df271c24", size = 1868374, upload-time = "2026-10-14T10:45:14.253Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/b3976fde0b81ae42b98151c7b155a986f8ecd1f9cedabcbbdcd4c2063f04/pycryptodome-4.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:73767e06cf75fb8ff86fd3cf77eba8e7614914d0c970fe1d41c216bf7b4b89c1", size = 2520237, upload-time = "2026-10-14T10:45:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/1e/48/0cb9d43d19045b47a19a0191bca3f926ad4c47147f4b01a5c85d7d31878c/pycryptodome-4.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fbf39c7f0c6fc3be114d60ebed14a8c219cd3ea19e6c4b14d16f1550d418e134", size = 1872056, upload-time = "2026-10-14T10:45:18.613Z" }, + { url = "https://files.pythonhosted.org/packages/56/ec/c874f25b18b633f4a8f27a5925237310d37dad2b975b63b2da1401d5d2d5/pycryptodome-4.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cd85d4970ddd20afb08a149cff4ca3bf606f4fe3dd245535dd079a1e752ffeb", size = 2601557, upload-time = "2026-10-14T10:45:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/05/19/e103a1a7d9b1bc9bb66783be7fb77d1ee7d65e3121ca0163295696630d72/pycryptodome-4.0.0-cp39-abi3-win32.whl", hash = "sha256:fdf963015e74982507c4c09961c2ec3213afc9cd991bb1c8f875ec2caac97d37", size = 1167435, upload-time = "2026-10-14T10:45:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ba/c86194cb41d374837988161242af4217db5a10c9bc93e4136373bf5c07c6/pycryptodome-4.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:077819384ceb90461af9c398c1dfdb7da01a6e17b7c98817831404fb5bd93c1f", size = 1210289, upload-time = "2026-10-14T10:45:25.484Z" }, + { url = "https://files.pythonhosted.org/packages/4e/40/51a1d6234014fb8d68169cc53b44d1b315c30a261794460fef66d3df084c/pycryptodome-4.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:4aea6fe5e78dda66a369d23f49fc69cfc433f8e1a1d36bda3d0466f69860ccb2", size = 1155633, upload-time = "2026-10-14T10:45:27.397Z" }, + { url = "https://files.pythonhosted.org/packages/63/37/6b2dd148a407a89027e17d0768752303843f19b1760b733e46ffda01f14b/pycryptodome-4.0.0-pp311-pypy311_pp80-macosx_10_15_x86_64.whl", hash = "sha256:327f55a5bdf41db353e3b3ed982324886a830ab70c9942c8c617bb7f21ce7b16", size = 1162933, upload-time = "2026-10-14T10:45:35.562Z" }, + { url = "https://files.pythonhosted.org/packages/06/93/520a6e790c09428d12ec8c377df1ad34927ab2fd4157b0b01ddf8164f14c/pycryptodome-4.0.0-pp311-pypy311_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2509bb14ae9811b9613df7b68ac0db267b102ed39908d73a094658b5f8b1424c", size = 1120814, upload-time = "2026-10-14T10:45:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/140d4684cec26572e3c8bcc9e596845d405c8eff381a791fd1418c2434d7/pycryptodome-4.0.0-pp311-pypy311_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a95a73d7af0e1ecfe93353aaf6bb659fc759144c13ce1d16c372b2a9d0cd584", size = 1240786, upload-time = "2026-10-14T10:45:39.18Z" }, + { url = "https://files.pythonhosted.org/packages/33/d5/bd2da15259fba8706ad761d5c830a1e66e1698fcd6810c07eef0772dc2e1/pycryptodome-4.0.0-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:ce84b3166a62b737da74bda2de253a4586b328019400a6508a79e9d2d0710b12", size = 1213211, upload-time = "2026-10-14T10:45:41.02Z" }, +] + +[[package]] +name = "pyfmodex" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/ed/6106a55d98868c0edb82aa8ee4fa5fa89712acbe2ab5a307adf962ac67fb/pyfmodex-0.7.2.tar.gz", hash = "sha256:d9ad3d7841a1c4cf4733cbedc69389f84c58a640d23d0ffa083ad6080f35103c", size = 111830, upload-time = "2024-03-08T07:41:32.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/7b/f7fdcdf6a6e8fd951c41e91baae8c1cecfffc2ed3199157afd6ab5728716/pyfmodex-0.7.2-py3-none-any.whl", hash = "sha256:1c42112b788e80ee7b4b57ae25bbbc0a98eb1e32a8e7c9e1246698cdf72efa9d", size = 126703, upload-time = "2024-03-08T07:41:30.326Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "12.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/fe/ac50ee24a80a1b344e643f6fb626063cc7041b978cd1dd9a7f9963ae1cd1/pymdown_extensions-12.3.tar.gz", hash = "sha256:a7f2a4e0bc60c6e6babfa7922cfb3b01cd640eb00fb208f3c582e12bae00ea67", size = 870532, upload-time = "2026-10-14T22:40:43.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/d0/758afe732c2da502c051b4d03ff88daa3c0fc1a1e671e6c5a6318177bab4/pymdown_extensions-12.3-py3-none-any.whl", hash = "sha256:345493a8e3497e24382902903a1c43a28e34a52f2b7571a1c9641429b820ca74", size = 277597, upload-time = "2026-10-14T22:40:41.543Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948, upload-time = "2026-10-04T16:32:36.469Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/6d/ddb8dfcf41892ba9c672d27988592c038d30db61708230313831b6780a1b/rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2", size = 344947, upload-time = "2026-10-04T16:28:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b2/632c3d034961149eff606e3e357547620a9ab37b849ae5815f67838753fd/rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044", size = 346770, upload-time = "2026-10-04T16:28:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/6d/45/4df346410bb873d03ec60c3b891d1fceb8cabde1971d29844bbb625cc296/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa", size = 377795, upload-time = "2026-10-04T16:28:58.193Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b8/a70f302207e4f29f873c01a986fcac5def49b9c8224328cef1ad7da6b7a9/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52", size = 383137, upload-time = "2026-10-04T16:28:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/13/e3/3aa28a48a01169d0e20728bf0e5ebbea3ae2774285b40853ae49b3ef332a/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f", size = 496789, upload-time = "2026-10-04T16:29:01.011Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a7/414e8d088602d66ad6f47c65bf07fe5090992a8574cdd4571c1a83b647ed/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7", size = 395929, upload-time = "2026-10-04T16:29:02.465Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/aac3b960fd55d37b49f77c0eebbe043eaf36762ebd48e7b46951e59c1c05/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff", size = 375426, upload-time = "2026-10-04T16:29:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/d3/80/77987a41c40be7202a78d9aed4a650bfb05792237cfd3b9e22638d990fe5/rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e", size = 381497, upload-time = "2026-10-04T16:29:05.308Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d0/640b02eb1ecd71a913c1178b6235287b31006266bca9a26862e80e497b54/rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888", size = 405740, upload-time = "2026-10-04T16:29:06.578Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8c/d034195d45d215625a23f2ae85dbbdb86a49a93a51272061c97c80b9d316/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834", size = 555850, upload-time = "2026-10-04T16:29:08.115Z" }, + { url = "https://files.pythonhosted.org/packages/de/f8/532f1addfc86207bc3cf23749638bb0d457bce891595012ed8a08d0543b8/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391", size = 618661, upload-time = "2026-10-04T16:29:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/50/5b/b0f2c9833a36a5b1f6ac5cb0e635fabacf713266f46ceefba2102da0bf51/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a", size = 583530, upload-time = "2026-10-04T16:29:11.072Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/464ed4b98d3c8c6a334f3b5e24d3886de04b05658a95fc40a37e0e6e4775/rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1", size = 208125, upload-time = "2026-10-04T16:29:12.408Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/384a9b62f8cd89c8e3e609b8c9052c140e8c01d807261e0517c904b8bf29/rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f", size = 226045, upload-time = "2026-10-04T16:29:13.751Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/0a691f0dcb2301fa9c736346da2d0d4991778373283301c5c12b8f952f3d/rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12", size = 222636, upload-time = "2026-10-04T16:29:14.989Z" }, + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822, upload-time = "2026-10-04T16:29:16.258Z" }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380, upload-time = "2026-10-04T16:29:17.706Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053, upload-time = "2026-10-04T16:29:19.063Z" }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742, upload-time = "2026-10-04T16:29:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289, upload-time = "2026-10-04T16:29:21.958Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710, upload-time = "2026-10-04T16:29:23.378Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261, upload-time = "2026-10-04T16:29:25.1Z" }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075, upload-time = "2026-10-04T16:29:26.748Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358, upload-time = "2026-10-04T16:29:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267, upload-time = "2026-10-04T16:29:29.851Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018, upload-time = "2026-10-04T16:29:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509, upload-time = "2026-10-04T16:29:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401, upload-time = "2026-10-04T16:29:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923, upload-time = "2026-10-04T16:29:35.709Z" }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415, upload-time = "2026-10-04T16:29:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229, upload-time = "2026-10-04T16:29:38.82Z" }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514, upload-time = "2026-10-04T16:29:40.405Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611, upload-time = "2026-10-04T16:29:41.778Z" }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712, upload-time = "2026-10-04T16:29:43.242Z" }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106, upload-time = "2026-10-04T16:29:44.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795, upload-time = "2026-10-04T16:29:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794, upload-time = "2026-10-04T16:29:47.603Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673, upload-time = "2026-10-04T16:29:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758, upload-time = "2026-10-04T16:29:50.601Z" }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370, upload-time = "2026-10-04T16:29:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106, upload-time = "2026-10-04T16:29:53.655Z" }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235, upload-time = "2026-10-04T16:29:55.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293, upload-time = "2026-10-04T16:29:56.669Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409, upload-time = "2026-10-04T16:29:58.049Z" }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681, upload-time = "2026-10-04T16:29:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178, upload-time = "2026-10-04T16:30:00.865Z" }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878, upload-time = "2026-10-04T16:30:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963, upload-time = "2026-10-04T16:30:04.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975, upload-time = "2026-10-04T16:30:05.555Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347, upload-time = "2026-10-04T16:30:07.156Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962, upload-time = "2026-10-04T16:30:08.77Z" }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623, upload-time = "2026-10-04T16:30:10.501Z" }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997, upload-time = "2026-10-04T16:30:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169, upload-time = "2026-10-04T16:30:13.804Z" }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615, upload-time = "2026-10-04T16:30:15.332Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655, upload-time = "2026-10-04T16:30:16.92Z" }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602, upload-time = "2026-10-04T16:30:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315, upload-time = "2026-10-04T16:30:20.05Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852, upload-time = "2026-10-04T16:30:21.604Z" }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709, upload-time = "2026-10-04T16:30:23.036Z" }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596, upload-time = "2026-10-04T16:30:24.558Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114, upload-time = "2026-10-04T16:30:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197, upload-time = "2026-10-04T16:30:27.699Z" }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025, upload-time = "2026-10-04T16:30:29.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969, upload-time = "2026-10-04T16:30:30.995Z" }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905, upload-time = "2026-10-04T16:30:32.628Z" }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569, upload-time = "2026-10-04T16:30:34.257Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482, upload-time = "2026-10-04T16:30:35.997Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040, upload-time = "2026-10-04T16:30:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849, upload-time = "2026-10-04T16:30:39.443Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842, upload-time = "2026-10-04T16:30:41.231Z" }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606, upload-time = "2026-10-04T16:30:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341, upload-time = "2026-10-04T16:30:44.954Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843, upload-time = "2026-10-04T16:30:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463, upload-time = "2026-10-04T16:30:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051, upload-time = "2026-10-04T16:30:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143, upload-time = "2026-10-04T16:30:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604, upload-time = "2026-10-04T16:30:53.206Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790, upload-time = "2026-10-04T16:30:54.925Z" }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146, upload-time = "2026-10-04T16:30:56.625Z" }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022, upload-time = "2026-10-04T16:30:58.295Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092, upload-time = "2026-10-04T16:30:59.984Z" }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830, upload-time = "2026-10-04T16:31:01.721Z" }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478, upload-time = "2026-10-04T16:31:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173, upload-time = "2026-10-04T16:31:05.281Z" }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653, upload-time = "2026-10-04T16:31:07.065Z" }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498, upload-time = "2026-10-04T16:31:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121, upload-time = "2026-10-04T16:31:10.84Z" }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741, upload-time = "2026-10-04T16:31:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719, upload-time = "2026-10-04T16:31:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267, upload-time = "2026-10-04T16:31:16.827Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555, upload-time = "2026-10-04T16:31:18.931Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346, upload-time = "2026-10-04T16:31:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373, upload-time = "2026-10-04T16:31:22.614Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371, upload-time = "2026-10-04T16:31:24.768Z" }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132, upload-time = "2026-10-04T16:31:26.588Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642, upload-time = "2026-10-04T16:31:28.677Z" }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855, upload-time = "2026-10-04T16:31:30.802Z" }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573, upload-time = "2026-10-04T16:31:32.709Z" }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840, upload-time = "2026-10-04T16:31:34.565Z" }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764, upload-time = "2026-10-04T16:31:36.665Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795, upload-time = "2026-10-04T16:31:38.53Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ad/f0eba548e297987964ef8088403db875b333963bf8189f3f563b6936b20e/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30", size = 384112, upload-time = "2026-10-04T16:31:42.643Z" }, + { url = "https://files.pythonhosted.org/packages/33/89/891fa4d81f1eae7fe262ecd0aba2c51de94a116c7f3ccfb034fa75f4c537/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050", size = 389662, upload-time = "2026-10-04T16:31:44.642Z" }, + { url = "https://files.pythonhosted.org/packages/4f/46/912cf623b2263f9c9c2d04e8508fc38dbe89983faeba5fb36ca4558ae550/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb", size = 504637, upload-time = "2026-10-04T16:31:46.667Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5c/7f3cfa7c5f01765c9f47c8398210ed0c1eb686c0e99c223cb1c6776d3fb9/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df", size = 402607, upload-time = "2026-10-04T16:31:49.068Z" }, + { url = "https://files.pythonhosted.org/packages/f0/76/2e915d4115ee453c33e6af17bc1df7e22ddc52974e178eeeb92a78c09983/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a", size = 382762, upload-time = "2026-10-04T16:31:51.24Z" }, + { url = "https://files.pythonhosted.org/packages/0d/e5/844382925d7b84a4074ad79fc2b9d05eb6231ea243b34f247e68e519b606/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608", size = 389563, upload-time = "2026-10-04T16:31:53.549Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/d6e3951602dc2e24790ef9d299aa24bf5e2493df5043f2c6da00480cc447/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9", size = 416842, upload-time = "2026-10-04T16:31:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/ea/1c/8d248e4ea671d525ab7daa0703ad81cc2175076fa0ccd7ef088facbd2b01/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c", size = 562434, upload-time = "2026-10-04T16:31:57.874Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/e7ac0db898120d15dea25e3661b1242a40cb3b23d1f4db86c15a01fe0ba1/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f", size = 627909, upload-time = "2026-10-04T16:31:59.977Z" }, + { url = "https://files.pythonhosted.org/packages/03/c8/37e555514932cb1ee36c75349187aa882a98dee5d5654c761699ed8a0d27/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e", size = 593658, upload-time = "2026-10-04T16:32:02.14Z" }, + { url = "https://files.pythonhosted.org/packages/12/5c/5f69a91add94a8a33acea68a2b034a464946a654ff7296af9d957ef0d6a5/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32", size = 351769, upload-time = "2026-10-04T16:32:04.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/22/35ea8bf3b2682b90ab687c74e4828418ff12298a5b0b0a0c033f61c86a30/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31", size = 351719, upload-time = "2026-10-04T16:32:06.834Z" }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109, upload-time = "2026-10-04T16:32:08.994Z" }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222, upload-time = "2026-10-04T16:32:11.321Z" }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384, upload-time = "2026-10-04T16:32:13.478Z" }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661, upload-time = "2026-10-04T16:32:15.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748, upload-time = "2026-10-04T16:32:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731, upload-time = "2026-10-04T16:32:19.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131, upload-time = "2026-10-04T16:32:22.285Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193, upload-time = "2026-10-04T16:32:24.4Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448, upload-time = "2026-10-04T16:32:26.844Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126, upload-time = "2026-10-04T16:32:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108, upload-time = "2026-10-04T16:32:31.641Z" }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877, upload-time = "2026-10-04T16:32:34.061Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.5.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "texture2ddecoder" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/25/603483a6c5f0fcd694732cbb079c83a026aa4ff153278a1f400c2bdf65e7/texture2ddecoder-1.0.6.tar.gz", hash = "sha256:88f04d8951018ac13b8dacd07e57e8adad4a6f1cfe5509646e8a1020346a81ca", size = 105397, upload-time = "2026-01-16T15:11:25.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/b2/b01747f95b21e314b96d54a00d99f7ab0b3987928dbfdf312fc01b527be4/texture2ddecoder-1.0.6-cp311-abi3-macosx_10_9_x86_64.whl", hash = "sha256:bc09a8f3b5717c8a4a41be57a0a39dee3378aebfcc3ed0a25331728496886548", size = 94222, upload-time = "2026-01-16T15:09:36.802Z" }, + { url = "https://files.pythonhosted.org/packages/af/39/e95286fdff39227eef8741c3138dfec22bbbaf4bea966ae081df1e664ba5/texture2ddecoder-1.0.6-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ebb12555f8774a2e497c92803102224546abe4d9bb8d10c77c2f300f07ce6ed9", size = 89260, upload-time = "2026-01-16T15:09:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/5d/70/3245c869bbe9f00dcb865982eabac8457c4aa46a4f8827460d7f8c5b493b/texture2ddecoder-1.0.6-cp311-abi3-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b112db3f37a644676bbace585998b17ea09c314f75b85994b31f3857bc4aefc3", size = 380941, upload-time = "2026-01-16T15:09:39.888Z" }, + { url = "https://files.pythonhosted.org/packages/0d/50/4f33e03fdf5a09170bb84b71d360a4f7629fe0ea21a97af3ac2a5616fb84/texture2ddecoder-1.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e83343e9ec30e2c9a5c83c1ae39ff6ec3010bf92b00ddb612f764066528f02bf", size = 377225, upload-time = "2026-01-16T15:09:41.317Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2c/170e994f776fff22b94af40efd3344bdc1ef53004e0cdf58bfa30473274a/texture2ddecoder-1.0.6-cp311-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1f1572d2ac8c631e836d2f49a7667781c6c6d763b3bbb34ab4a76b65fff698e", size = 443978, upload-time = "2026-01-16T15:09:43.871Z" }, + { url = "https://files.pythonhosted.org/packages/e8/32/b37f3f2aad2065c6eb9f2665426cab61133297e57a63950bab14d6e4bee2/texture2ddecoder-1.0.6-cp311-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:661c9c49d5af4e8d9fc6959cd1efb55fc11738ddac68ddc684a1982ef3baf99e", size = 443383, upload-time = "2026-01-16T15:09:45.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3b/d3f7598b2ac40d3e7eb2062a3d27e37ba574ab22a5d20024e81709941120/texture2ddecoder-1.0.6-cp311-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3fddfbe49d707531333e080e740121cd4d79780cc4b6b45b7a50d73fc0cb141", size = 498810, upload-time = "2026-01-16T15:09:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/8b/89/4bb28293d4b49834386a81e9f89008dc872da5ee0564e370f560e16869a2/texture2ddecoder-1.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09b98ea4d66e21aca42558727dc609a8a952e2b6eb09d0ee8e0e52ba14b7848a", size = 399552, upload-time = "2026-01-16T15:09:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/80/66/bf5227a1e5b48c3347c685ad33b1b55c75ffb6bf8b077bfa3f8090b7cff3/texture2ddecoder-1.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:994398efe56552e78180852caf01d58d54bf2a2319fce6e02e0546856ef12a7c", size = 1347110, upload-time = "2026-01-16T15:09:49.802Z" }, + { url = "https://files.pythonhosted.org/packages/6e/63/c791e427078ea4d103f9b0156b66b65e517404bf6a8b05be3309e11550ff/texture2ddecoder-1.0.6-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:7e02b9e5b073f902697999fdd74c1d751fa9729d61091f9bd59e313dcf02e616", size = 1204361, upload-time = "2026-01-16T15:09:51.445Z" }, + { url = "https://files.pythonhosted.org/packages/4b/84/f081060de34542652d6c61f3586bdbe653c44badc13936f1d9421b1ffa35/texture2ddecoder-1.0.6-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:778b98f52085ea5024168929e7ae71fd73c28a8b6963a2c14802fcfe399cf51b", size = 1515869, upload-time = "2026-01-16T15:09:53.125Z" }, + { url = "https://files.pythonhosted.org/packages/ce/1c/1856d12971abff1e15f2ab13925a241ace84143255a16f24bc1a1356f6ce/texture2ddecoder-1.0.6-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a2432186fb81e0e54542d83e51c1aee040499d9a54478347502cc36252d45171", size = 1490845, upload-time = "2026-01-16T15:09:55.582Z" }, + { url = "https://files.pythonhosted.org/packages/34/66/e7165b285a3a149c756e2555c6c970a1379bc6c547afd3c56fbd72b30757/texture2ddecoder-1.0.6-cp311-abi3-musllinux_1_2_s390x.whl", hash = "sha256:60c557cfcb0d0915d5181f7fb79bae2af82dcb1e6d7a48db5a6afcaae400c34c", size = 1643655, upload-time = "2026-01-16T15:09:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/f2/79/651391002956a5218fc26ab74ee19fe52e4593231b87ef44ad8e1f556891/texture2ddecoder-1.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b5e758fb4162be3edc980ed6c557ad671449ff4fc184739f7ed7d1a66ebc1a5e", size = 1422265, upload-time = "2026-01-16T15:09:58.397Z" }, + { url = "https://files.pythonhosted.org/packages/f6/5f/0d13c81d3dc260e3c19036eede7786bb2fa194eeadf35173a517fde85a5c/texture2ddecoder-1.0.6-cp311-abi3-win32.whl", hash = "sha256:d4acc52adc2a2fa03ae8300240c24b2a503f3fc87f77ebb93ab97b6683e18fdd", size = 52872, upload-time = "2026-01-16T15:09:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f8/cbbefcb1b3435aea8ef1b3d65622c933f79ea7c70af4d4571f667277cc86/texture2ddecoder-1.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:1bad8209d9dfbfc7daf13ea2e545236ea2bbc1e423b2992b6e7563909975a47b", size = 64500, upload-time = "2026-01-16T15:10:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/94/27/a2c301a3e37c27883fce906c8d7a917880658a949c4e002751770c2899ae/texture2ddecoder-1.0.6-cp311-abi3-win_arm64.whl", hash = "sha256:77839c81287fdef2409ce664fe059ecc6207ed2b511f1972bdb9971e7555963e", size = 58246, upload-time = "2026-01-16T15:10:02.218Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a5/bf0e4a42b36807d31133297183a146135b1a5995a9ca215d31cba271ee8b/texture2ddecoder-1.0.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0a13d55588014972a0f392a6cc7af562d5140071d90e2f2383fe03d5bcdb8be2", size = 79866, upload-time = "2026-01-16T15:11:04.265Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b7/88e9afd61284e54ebbcae0514d0d436d1a8fbd5360fde8420c577fc3d8ee/texture2ddecoder-1.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7faf71031a57b51357718e9b82e0fd2add0e04c465d1ad819e5b77cce4b42f77", size = 71362, upload-time = "2026-01-16T15:11:05.332Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0e/8ac0203e07ee41fe7230d799b8debf99082364703bf78b68680f32ae636f/texture2ddecoder-1.0.6-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6c9cf648bb6d8b5e68efa0255903921fc679d9799d88278e8e0b16814a77c6f3", size = 85256, upload-time = "2026-01-16T15:11:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/05/18/b26d73a72788e49f9c93036a961c63b0464bed3f92da8dc933e30144abc8/texture2ddecoder-1.0.6-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5033c37b9b2f4dc7b082e34006fa69b684eafc20ca0d2315ce46aaf4924941b", size = 75498, upload-time = "2026-01-16T15:11:07.565Z" }, + { url = "https://files.pythonhosted.org/packages/bd/51/ee325c235c0b5797b6587a8cb43c8a649b3969da40438745fffea86c76c2/texture2ddecoder-1.0.6-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d37e99688e99ea3f1f9a65872863daddd683c44800dd2e27e15cf2b506eb304", size = 81592, upload-time = "2026-01-16T15:11:08.71Z" }, + { url = "https://files.pythonhosted.org/packages/09/69/fa75dd386e4de418763c15a06afce40efb37ad1356909cd2ca4a25a981f9/texture2ddecoder-1.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:71bf13add7119753a702935b0aae716c150933f9e65fe62fb96254ef8902bbdf", size = 64614, upload-time = "2026-01-16T15:11:09.818Z" }, +] + +[[package]] +name = "tpk-ar" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/02/654a73cbd323970b5c1551881968dbadec1d697984fff5fdd33b66764dfc/tpk_ar-0.2.4.tar.gz", hash = "sha256:d64897e5a0bcddd58e8a7d4dd922f372da984a0f98bc3b460ec869aab906ee78", size = 10796, upload-time = "2026-08-01T17:52:56.473Z" } + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "unitypy" +version = "1.25.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astc-encoder-py" }, + { name = "attrs" }, + { name = "brotli" }, + { name = "etcpak" }, + { name = "fmod-toolkit" }, + { name = "fsspec" }, + { name = "lz4" }, + { name = "pillow" }, + { name = "texture2ddecoder" }, + { name = "tpk-ar" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/a4/5b1c786cae645d30c882eac7933ab7c8916e13f1a571bf7d0f2cdc942a52/unitypy-1.25.4.tar.gz", hash = "sha256:91d6169d73db8eb5f11fd6531b4e5e980f53dae00d5e3cbab827bbf4765de6d3", size = 389119, upload-time = "2026-10-03T10:05:38.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/be/44e54aa94618a2915b4fad53d6f58bb2eb6f54b27f647e1295a2b7f26eb2/unitypy-1.25.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:134b8dd8bd6300e413e86c8ee0ffcbd579852fd6cf991d9e0811a85877630bb9", size = 446279, upload-time = "2026-10-03T10:04:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/4c3c7ded2f832b4d88139f0f63e1ef2346619f9439d130f6173c538bdc82/unitypy-1.25.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aa57e55aacd89076ba3b0bfb160faed4651008a60eb47f6009274a68e375c147", size = 442219, upload-time = "2026-10-03T10:04:10.882Z" }, + { url = "https://files.pythonhosted.org/packages/aa/52/a2738bfe52e89a144e5ad041cc23743e9404905de90e0db83fa91d811c06/unitypy-1.25.4-cp311-cp311-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:e469f8321457c417e40c9cce8e81e924855e9fabad581621beec720215d3f653", size = 1308836, upload-time = "2026-10-03T10:04:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/f5/66/93425f6dc816341174354510746595f422fb1d0c7aa4dbf99245d49b1bd7/unitypy-1.25.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:835e34e856e72a17847b432ddf9dfb24a7aca4fbdef4d2c5cedafe4914a4b545", size = 1328642, upload-time = "2026-10-03T10:04:14.079Z" }, + { url = "https://files.pythonhosted.org/packages/84/55/2dc00a12df0a76cc2aed1f172c78aa695277e64e6f2ede5740e0becc1b5f/unitypy-1.25.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:abaee10a59e8864856fd2b755bfec51a2f5dc2ae117176a2d694b84386c02bd4", size = 2441917, upload-time = "2026-10-03T10:04:15.362Z" }, + { url = "https://files.pythonhosted.org/packages/a3/41/35144d167fd02398fb10a096d05fe86f69b347ebf30dc332529475b595a2/unitypy-1.25.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3dda5d3dc3554d0ef56cb122ce86a92be3ca0ada6f812ac68659e57cccdcb55b", size = 2340544, upload-time = "2026-10-03T10:04:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/3b/94/40cd48d9f1f18f14d90fceb258188a16e070d7527f394fc038fa6ec24b57/unitypy-1.25.4-cp311-cp311-win32.whl", hash = "sha256:774fe59d19e4413554b3c25200ea2c184885668bed6449011c5c8c2d8cb41305", size = 434158, upload-time = "2026-10-03T10:04:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7b/0a742f1de51005ff5211a657de09268c71cd4c6c1dcb7b61d44cc926e041/unitypy-1.25.4-cp311-cp311-win_amd64.whl", hash = "sha256:750178fbe4e26f9ce73a4beafa7aaa351cc13b72ae734e9511e3cea8a740d524", size = 438623, upload-time = "2026-10-03T10:04:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/e70a4cecffd0a01f8ace68ba0fc691230482a2c79645dbef4a5b4b4a1f47/unitypy-1.25.4-cp311-cp311-win_arm64.whl", hash = "sha256:545683f01e959682e4c70a3be9bd7309e5706bb2d03924ca5b2d19d61abf1f59", size = 440072, upload-time = "2026-10-03T10:04:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a7/09/437d89c27e656603075d81429814d57e462dfbf7e13c88b215956b8cf5ae/unitypy-1.25.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9d221ef1a29b32e13bcc41be24585157a835245db4f3a9fc84db43ca12a7875c", size = 446507, upload-time = "2026-10-03T10:04:22.188Z" }, + { url = "https://files.pythonhosted.org/packages/01/cc/aaf0345808d4921ca8faa600949a0f6707e6be8a0b83cc0af3c09edfc2f5/unitypy-1.25.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e750e26b942d0c03312e9f8c195f8a18ceb4016792ada1bace5f0340799c5542", size = 442818, upload-time = "2026-10-03T10:04:23.525Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0a/7243a5ed5e3c3b380898193ee36745dcdf0a90763bccb6710c8cf4a05980/unitypy-1.25.4-cp312-cp312-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:da15a5373878776252c045e126ceb84870f2ff547965cc0b87a247ec7a37106b", size = 1306615, upload-time = "2026-10-03T10:04:25.017Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/39d6b4c9081314def979241e598e76b9c7954f5f06b2266ebe2e5f60f780/unitypy-1.25.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b219cceb76fcb4508f04d02a0b374c56c0c8f18ee1a201143b33b18da5e03a68", size = 1330041, upload-time = "2026-10-03T10:04:26.494Z" }, + { url = "https://files.pythonhosted.org/packages/f5/29/85cc73b875ebce2c8af36915079535c94c12754a7477fd596e5be37cbdad/unitypy-1.25.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:14f6a0d283966e9afa78e3c700270df1fb16f9804c17f300b9ff933c3853762b", size = 2429287, upload-time = "2026-10-03T10:04:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1d/90c5e72aae84ee17446feb1d648c225f92251c4e0bf6aa991c9203261b14/unitypy-1.25.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dc4431fda3e3c7367609e224be0cb55c1e8f9b9cc7e25cf0e36188ebeed4394b", size = 2342327, upload-time = "2026-10-03T10:04:29.465Z" }, + { url = "https://files.pythonhosted.org/packages/b8/92/11f4181b324d75dba941431b315913c3bf56697fe6bf797eeaef947f7b24/unitypy-1.25.4-cp312-cp312-win32.whl", hash = "sha256:3c94724941cdc239dc55a87b07112ab7ab3c0a9b4d927c02dd6b4c97bb46fe4e", size = 434351, upload-time = "2026-10-03T10:04:30.91Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ff/916b14f08ebbf4ee30a29e4b5bec592ed62dc0c0b9276a95f903a1da102c/unitypy-1.25.4-cp312-cp312-win_amd64.whl", hash = "sha256:5e3f6fa44e23e7fc52a32f128d709a97901e0d10e3d87e0b602c5f5a58e3a4ef", size = 438396, upload-time = "2026-10-03T10:04:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/f0225129fc3c81abd8e833de27146d89ad9072f197cfa159e0951513c1b1/unitypy-1.25.4-cp312-cp312-win_arm64.whl", hash = "sha256:8ecbc476ea8e093fa82daebd9805e7f81ad2bb3136572d32011262a42a48d240", size = 440031, upload-time = "2026-10-03T10:04:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/41e059a2d7ff7487cd6ee8b67e54dddde080c67a41c043bf724c52c7ba93/unitypy-1.25.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e922b3262d9dd4d6090e0b3a5c19b12fa2b79646ea5df05b2dbaa777a4be05ae", size = 446491, upload-time = "2026-10-03T10:04:35.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ac03a30f5ca9639b8abd3f30e76ec218c287723dca3705f44d71d97b9cb4/unitypy-1.25.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:05ce3cd2afee9886eb9579ff4848a67ccc8a749a7f906a0e08960df89169733a", size = 442812, upload-time = "2026-10-03T10:04:36.8Z" }, + { url = "https://files.pythonhosted.org/packages/c0/0d/47f4aa517d06d98634551178586e1d7f617b48b7ae9c36109d8f849df40f/unitypy-1.25.4-cp313-cp313-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:ab65143b22601bb3146cc489be3389ada287f27c632914d6bd400292c100f54c", size = 1306458, upload-time = "2026-10-03T10:04:38.109Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e7/30de2189ece5f0896ed484662dbd97ad5b9731cc17cf9efe3642da01ec8f/unitypy-1.25.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a50ca709280cb6dd3da12038c7a7fc001767b9a8f1ab93cb2ecc8838cf49eb1f", size = 1329943, upload-time = "2026-10-03T10:04:39.402Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cc/1754269936e48b1f2bad4532eab7326c343a0c368fc5e9dd70b1d4b6c363/unitypy-1.25.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df5e50370f3a8c0cea66ec0ceb9ce42d482a417004f03e5ad3f04fc6efdce120", size = 2429458, upload-time = "2026-10-03T10:04:40.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/44/554ce0046d1b143241c247f0aaf6f918fcbaab3b12a46692f9ae2ea7e1f9/unitypy-1.25.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9279542e8729bfccf394fe39c9c50ed3e210778a058cf4e79aa25e1f5db339c3", size = 2342451, upload-time = "2026-10-03T10:04:42.336Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e51fc5ffaa157e3a72b7692696c8fd2582d7987687ed9251570e6cefb7ff/unitypy-1.25.4-cp313-cp313-win32.whl", hash = "sha256:f9123de4065e3a87c587519fda78bfc752225f79cd8a622f1293df762619e515", size = 434347, upload-time = "2026-10-03T10:04:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/46e1051dc09be92eb08d358ea44f7fcf68091d8e8e863d0f749f12d523da/unitypy-1.25.4-cp313-cp313-win_amd64.whl", hash = "sha256:4107c14c551d5978484b5d7852190bc1d9283eaab4b7657be8ad150c8facf711", size = 438393, upload-time = "2026-10-03T10:04:45.25Z" }, + { url = "https://files.pythonhosted.org/packages/75/f3/59c83695d491656ff83f88969540ece38f34c5735e527ee7ae171bce2a0b/unitypy-1.25.4-cp313-cp313-win_arm64.whl", hash = "sha256:220634590b9145a070d39c003c2c0f6ae146c03754e4743cae5eacedf6da9a91", size = 440029, upload-time = "2026-10-03T10:04:46.504Z" }, + { url = "https://files.pythonhosted.org/packages/05/32/2545b64d8287d311ca15d156a92e1ba39aa13138da61cca7194e575ec886/unitypy-1.25.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:088d6bd8a29bde1a6c629e047cce52d795fdc8f76e84d95932639eac4c3685ee", size = 446739, upload-time = "2026-10-03T10:04:47.827Z" }, + { url = "https://files.pythonhosted.org/packages/dd/7d/e51607f45f07477ff64f85ec5a1b3ad8e81ceb7027db9d7664047cae2fd4/unitypy-1.25.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f6e6256d94ff80c917659ef5c561a7b77e811bce5baee5a9a648a4226fbe501", size = 442982, upload-time = "2026-10-03T10:04:49.18Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/b6c605fd3ef782a102716c97b1deaf5a8c7a264246add3b931fa5d66dcc1/unitypy-1.25.4-cp314-cp314-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:5e28dec3188cb356e5c55820bbab0ddabc52d0e4f54a098df951875ad375cfb7", size = 1304436, upload-time = "2026-10-03T10:04:50.522Z" }, + { url = "https://files.pythonhosted.org/packages/46/33/ce57aefa2b3059be43ef14b3bb67cdaffdac19e34bbcba88129f45660472/unitypy-1.25.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2b3ecb5b063bd4e8962540bd1790d5211becc3a6dd1be6fbe8896bd61fd086d1", size = 1330820, upload-time = "2026-10-03T10:04:51.847Z" }, + { url = "https://files.pythonhosted.org/packages/3c/91/c76ede74c472d450a39cd8cc76bff3eeaec2b98914d81dab50f5a70e1184/unitypy-1.25.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:589288c4927671d6df00faa27d826ff8ebccbf00a415a08194d664172e74890e", size = 2431054, upload-time = "2026-10-03T10:04:53.602Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/44ce276b1d2c70537f40117cb8b106d0f94af024fbe6d9738c8359cb0c9b/unitypy-1.25.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1666b925a864e8154b922c93b55a62692db31c000b8c95f271d371697ce2b201", size = 2342769, upload-time = "2026-10-03T10:04:55.006Z" }, + { url = "https://files.pythonhosted.org/packages/59/8b/9b44c89be7e3dbb7aecf4edbd6c04366cccc813725b46296914d22b8113d/unitypy-1.25.4-cp314-cp314-win32.whl", hash = "sha256:3900318cb2c8680957ce12a54fdc52e80d9843f7227ff40559db5db20693d45b", size = 435883, upload-time = "2026-10-03T10:04:56.351Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/fbc390281cfb92eba9e42a8fdcf8a06a26ca774f20d0bfb9cc3ba94f2a0d/unitypy-1.25.4-cp314-cp314-win_amd64.whl", hash = "sha256:d0349da223c9531e295204b1b987f60dad1958cbc8ea20fc5bb75b28fabcb792", size = 439691, upload-time = "2026-10-03T10:04:57.77Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/8af3b624e04efaa88bb0a735e777d71715b67fa432f03834c9ef9b924831/unitypy-1.25.4-cp314-cp314-win_arm64.whl", hash = "sha256:5bb6995de826e325efcfabb00c75ec359280e1341f397c5a67f5ea57f3a228a4", size = 441698, upload-time = "2026-10-03T10:04:59.156Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ca/5de27ea0df61f0f1c37bea7530401bf4f3bb265529b59e32b65e97875cf6/unitypy-1.25.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8a79f4b7bceeb3d473102c637024df006292df0cc62e2917d124670036f08ed9", size = 448125, upload-time = "2026-10-03T10:05:00.57Z" }, + { url = "https://files.pythonhosted.org/packages/b5/43/5e180770758cebc3559d4b26fe9a76533434d5f661eb8b31dbc9601b9093/unitypy-1.25.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f222ef92a7c689c231ff7e718eb19a8c618c0ce095d6e712532fed07d6ea42ac", size = 444213, upload-time = "2026-10-03T10:05:02.098Z" }, + { url = "https://files.pythonhosted.org/packages/f1/76/174f62de7dc195b179a97cd28098f7f14f3a3d0578ed29e66fa19ff95a82/unitypy-1.25.4-cp314-cp314t-manylinux_2_24_i686.manylinux_2_28_i686.whl", hash = "sha256:6d6669b2a61dc1cb79dbfef40809cd5b1b918a8545da8e2dcac261d38ef206a6", size = 1300583, upload-time = "2026-10-03T10:05:03.44Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4b/3842cc082027c1017fd918717f28515e361fecb0a06e252ba9bb6be9bb91/unitypy-1.25.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df14c55af38128e7b0767ba7409b24240f652dac1aebd566d7cf473c5b4eacd6", size = 1324348, upload-time = "2026-10-03T10:05:04.971Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/59ad4442e97e8be74886fced4f57705e59a5ff54a875d8db86a16e6b74bc/unitypy-1.25.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d22e5872e4f5a1448a5eec577db892adcc2e571148de4bc8345c64457fa88823", size = 2426571, upload-time = "2026-10-03T10:05:06.303Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3c/082065ab96367a9c5257b5a6ce2df6cb03cb975c46073ec888c3e8f7e0cf/unitypy-1.25.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4b94ce1b39c462343af3bd779b17348a0e84168a8ef3566e7b2b1fc408540194", size = 2339656, upload-time = "2026-10-03T10:05:07.811Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fa/0f9a29f6cfcb982b842a7d1cc18be85c0eac6abd859d4a3278439648ee16/unitypy-1.25.4-cp314-cp314t-win32.whl", hash = "sha256:0bac3531a0f20c0803c9f5628a0da67fb45adcc2627494044fd26f6c8ac4eb75", size = 436475, upload-time = "2026-10-03T10:05:09.189Z" }, + { url = "https://files.pythonhosted.org/packages/56/62/16db23462ef2aef183c4829b1a906ab0ebacd5cec30b3bed2e283c4376cf/unitypy-1.25.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8971915ed1f8c497a5287bc8c1eb956ce0d59365f335806189e38da3f8ce4c58", size = 440343, upload-time = "2026-10-03T10:05:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/52/39/5f1fa1690d464e9e52610b2c56b3ccf776514bc17d5de74420bcebb5748b/unitypy-1.25.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d36169296acba9916bcbe6404651adc218db67c5c08794531f17e1bd6a91ddaa", size = 442538, upload-time = "2026-10-03T10:05:11.868Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +]