mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
134
Commits
No files matched your search
@@ -45,6 +45,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
|
||||
|
||||
@@ -7,6 +7,11 @@ welcome on [Discord](https://discord.gg/HcTeMZxe5E).
|
||||
This repository holds the game, the launcher and the website, plus the tools, tests and docs that build, check and
|
||||
release them. Anything else (videos, Discord and other community material) lives elsewhere.
|
||||
|
||||
**Where to start: [playhorizon.dev/hub](https://playhorizon.dev/hub).** It shows what is being done now and next
|
||||
([ROADMAP.md](ROADMAP.md) in the repository), the work board, good first tasks, and the forms to report a bug or
|
||||
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
|
||||
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
|
||||
|
||||
## Build it
|
||||
|
||||
You need Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers (`libasound2-dev libudev-dev`).
|
||||
|
||||
Generated
+29
@@ -428,6 +428,8 @@ version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"termcolor",
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
@@ -1266,6 +1268,7 @@ dependencies = [
|
||||
"horizon-mods",
|
||||
"horizon-protocol",
|
||||
"horizon-server-files",
|
||||
"horizon-shaders",
|
||||
"horizon-telemetry",
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
@@ -1434,10 +1437,25 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shader-api"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"naga",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-shaders"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-core",
|
||||
"naga",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -1973,6 +1991,7 @@ dependencies = [
|
||||
"naga-types",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"pp-rs",
|
||||
"rustc-hash 1.1.0",
|
||||
"spirv",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2649,6 +2668,15 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pp-rs"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
|
||||
dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -4197,6 +4225,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"js-sys",
|
||||
"log",
|
||||
"naga",
|
||||
"parking_lot",
|
||||
"portable-atomic",
|
||||
"profiling",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-shaders", "crates/horizon-shader-api", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<a href="#download"><b>Download</b></a> ·
|
||||
<a href="https://playhorizon.dev/docs/"><b>Documentation</b></a> ·
|
||||
<a href="https://github.com/ludovic111/horizon/releases"><b>Release notes</b></a> ·
|
||||
<a href="ROADMAP.md"><b>Roadmap</b></a> ·
|
||||
<a href="#project-status"><b>Project status</b></a> ·
|
||||
<a href="#contributing"><b>Contributing</b></a>
|
||||
</p>
|
||||
@@ -109,6 +110,10 @@ Everything prepared lands in the gitignored `.local/`. It is derived from the ga
|
||||
|
||||
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
|
||||
|
||||
## Roadmap
|
||||
|
||||
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
|
||||
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
# Horizon roadmap
|
||||
|
||||
<!-- Generated by tools/roadmap.py from roadmap/roadmap.toml: edit that file, then run `python3 tools/roadmap.py build`. -->
|
||||
|
||||
What is being done on Horizon, what comes next and where you can help. There are no dates: things are Now, Next or Later. Each item is said in plain words, with the technical picture folded under it. The same roadmap, with a Simple / Technical switch, the board, the journal and how to help, is on [playhorizon.dev/hub](https://playhorizon.dev/hub). Pull requests carry the `roadmap:<id>` label of their item.
|
||||
|
||||
## At a glance
|
||||
|
||||
Horizon is Minecraft: Java Edition 26.3 rewritten from scratch in Rust, so it runs faster on the same computer. Single player, LAN, servers, resource packs and Horizon's own mods work today, on your own copy of the game.
|
||||
|
||||
**Being done now**
|
||||
|
||||
- **Steady frames at long render distances**: Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
- **Modpacks' world generation, natively**: Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
- **Playing on servers: everything shows as it should**: When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
- **Single player as a server: the last differences**: Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
- **Horizon's mod registry**: One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
**Next**: Microsoft sign-in (coming soon); CurseForge in the mod browser (coming soon); Fresh benchmarks against vanilla; Pistons push players and mobs; Horizon on Intel Macs.
|
||||
|
||||
**How to help**: Play it and tell us what differs from vanilla (Discord or GitHub), run the benchmark on your computer, vote for what you want next, or pick one of the Help wanted items.
|
||||
|
||||
**Developers**: Read CONTRIBUTING.md, pick a Help wanted item or a To do card, check vanilla's code for the behaviour, and paste the block `python3 tools/pr_ready.py` prints in your pull request.
|
||||
|
||||
## Now
|
||||
|
||||
### Performance
|
||||
|
||||
#### Steady frames at long render distances
|
||||
|
||||
Making Horizon faster than vanilla on every computer, with smooth camera movement even at 32 chunks, by spreading the work better over the processor's cores.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Frame pacing at render distance 32 ([#678](https://github.com/ludovic111/horizon/issues/678)): long frames come from work competing with the render thread. In review: world generation workers build free structure starts instead of queueing them ([#1760](https://github.com/ludovic111/horizon/issues/1760)), the worker pool leaves one core as vanilla does ([#1778](https://github.com/ludovic111/horizon/issues/1778)), and the terrain vertex arena is read as storage where flat draws run ([#1742](https://github.com/ludovic111/horizon/issues/1742)). Still open: far terrain at 48 to 64 chunks ([#718](https://github.com/ludovic111/horizon/issues/718)) and CPU-bound fields of block entities ([#717](https://github.com/ludovic111/horizon/issues/717)). Every change shows before and after numbers from `tools/perf_bench.py` against vanilla 26.3 ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Faster block lookups
|
||||
|
||||
Speeding up the code that reads and writes the blocks of the world, which almost everything in the game goes through: generation, lighting, meshing and ticking.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`PalettedContainer` and `PackedStorage` find an entry's word by multiply and shift instead of an integer division ([#1790](https://github.com/ludovic111/horizon/issues/1790), [#1794](https://github.com/ludovic111/horizon/issues/1794)); meshing looks up baked models by index, not by name ([#1747](https://github.com/ludovic111/horizon/issues/1747)). Each change keeps the paletted storage harnesses identical and shows its microbenchmark ([docs/performance/paletted-container-access-results.md](docs/performance/paletted-container-access-results.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### Modpacks' world generation, natively
|
||||
|
||||
Making the world-generation mods of popular modpacks (new structures, YUNG's dungeons and mineshafts, packs built on Lithostitched) generate their worlds in Horizon, natively and fast.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-port-pack` carries a pack's data-driven content over; structure mods that need code get a native port: YUNG's worldgen as written ([#1746](https://github.com/ludovic111/horizon/issues/1746)), Lithostitched's limited and guaranteed elements with pool and processor modifiers ([#1777](https://github.com/ludovic111/horizon/issues/1777)), its processors as the game's own types ([#1765](https://github.com/ludovic111/horizon/issues/1765)). Proved by the structure blocks and layout harnesses ([#1753](https://github.com/ludovic111/horizon/issues/1753), [#1757](https://github.com/ludovic111/horizon/issues/1757)). Mods whose Java code does more than world generation don't run ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Servers and multiplayer
|
||||
|
||||
#### Playing on servers: everything shows as it should
|
||||
|
||||
When you play on a server, other players, mobs, items and menus look and behave as in vanilla: skins, poses, item names, inventories and server networks.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Client-of-a-server fixes in `game/remote*.rs` and `horizon-protocol`: mobs' aggressive and item-use flags ([#1802](https://github.com/ludovic111/horizon/issues/1802), [#1798](https://github.com/ludovic111/horizon/issues/1798)), ghost items after a remote menu ([#1795](https://github.com/ludovic111/horizon/issues/1795)), skins ([#1652](https://github.com/ludovic111/horizon/issues/1652)), cookies and transfer packets for proxy networks ([#1650](https://github.com/ludovic111/horizon/issues/1650)), firework boosts ([#1671](https://github.com/ludovic111/horizon/issues/1671)), living-entity attributes and simulation distance ([#1731](https://github.com/ludovic111/horizon/issues/1731)), styled item names ([#1702](https://github.com/ludovic111/horizon/issues/1702)), primed TNT between updates ([#1710](https://github.com/ludovic111/horizon/issues/1710)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Server dialogs and login screens
|
||||
|
||||
Servers that show dialogs, such as the register and login screens of AuthMe, will work in Horizon.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's dialog packets and model ([#1720](https://github.com/ludovic111/horizon/issues/1720)), the dialog screen with its buttons and actions ([#1734](https://github.com/ludovic111/horizon/issues/1734)), then text and boolean inputs ([#1735](https://github.com/ludovic111/horizon/issues/1735)).
|
||||
|
||||
</details>
|
||||
|
||||
### Mods and API
|
||||
|
||||
#### Horizon's mod registry
|
||||
|
||||
One place for mods, modpacks and resource packs made for Horizon: browse them on the website and install them from the launcher in one click.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Merged: the registry index (schema 1, `.hpack`, checks before install, [#1634](https://github.com/ludovic111/horizon/issues/1634)), `cargo horizon-mod check` ([#1642](https://github.com/ludovic111/horizon/issues/1642)) and the submission service (`crates/horizon-mod-registry`, [#1659](https://github.com/ludovic111/horizon/issues/1659)). In review: the website's /mods ([#1596](https://github.com/ludovic111/horizon/issues/1596)) and /mods/submit ([#1635](https://github.com/ludovic111/horizon/issues/1635)), and in the launcher the registry as a source ([#1636](https://github.com/ludovic111/horizon/issues/1636)), install, enable and takedowns ([#1637](https://github.com/ludovic111/horizon/issues/1637)), the Mods page ([#1638](https://github.com/ludovic111/horizon/issues/1638)) and modpacks as their own profiles ([#1640](https://github.com/ludovic111/horizon/issues/1640)).
|
||||
|
||||
More: [docs/mods.md](docs/mods.md)
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Single player as a server: the last differences
|
||||
|
||||
Single player now runs a small server in the background, as vanilla does. We are fixing what still behaves differently there; every fix also works for LAN and server players.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Since [#1514](https://github.com/ludovic111/horizon/issues/1514) single player runs on the server thread (`game/integrated.rs`) and the window joins it as a client; `--in-frame` keeps the old path. The open `[server-thread]` issues list the gaps. In review: a tool that changes a block ends the click ([#1797](https://github.com/ludovic111/horizon/issues/1797)), potions keep their potion over the connection ([#1796](https://github.com/ludovic111/horizon/issues/1796)), mobs' aggression and arm swings ([#1802](https://github.com/ludovic111/horizon/issues/1802)). [docs/server-levels.md](docs/server-levels.md).
|
||||
|
||||
</details>
|
||||
|
||||
#### Play in your language
|
||||
|
||||
Item names, container titles and server messages follow the language you pick, as in vanilla. Today parts of the game stay in English.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Translatable components resolved through the selected language for items, containers and server text ([#1806](https://github.com/ludovic111/horizon/issues/1806)), on top of the bundled languages the prepared resources carry ([#1594](https://github.com/ludovic111/horizon/issues/1594)). Left: the client's own screens ([#1672](https://github.com/ludovic111/horizon/issues/1672)) and the main menu with zh_cn ([#1774](https://github.com/ludovic111/horizon/issues/1774)).
|
||||
|
||||
</details>
|
||||
|
||||
### Rendering
|
||||
|
||||
#### Shader packs, written again from scratch
|
||||
|
||||
Shader packs were removed and are being rebuilt from scratch. They come back, off by default, once the best-known packs draw properly.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A clean-room rewrite in `horizon-shaderpack`, from the packs' public format documentation only: reading packs, the preprocessor and `.properties` files are merged ([#1584](https://github.com/ludovic111/horizon/issues/1584), [#1585](https://github.com/ludovic111/horizon/issues/1585), [#1586](https://github.com/ludovic111/horizon/issues/1586)); translating programs to WGSL through naga, buffers, uniforms and the terrain, entity, sky, weather and hand stages are in review ([#1698](https://github.com/ludovic111/horizon/issues/1698) to [#1801](https://github.com/ludovic111/horizon/issues/1801)).
|
||||
|
||||
More: [#1561](https://github.com/ludovic111/horizon/issues/1561) · [#1771](https://github.com/ludovic111/horizon/issues/1771) · [#1786](https://github.com/ludovic111/horizon/issues/1786)
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### Discord Rich Presence
|
||||
|
||||
Your Discord profile can show that you are playing Horizon, with a switch to turn it off.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A Discord IPC client on its own thread ([#1787](https://github.com/ludovic111/horizon/issues/1787)) and the presence with its setting ([#1788](https://github.com/ludovic111/horizon/issues/1788)). Merged: it ships with the next release.
|
||||
|
||||
</details>
|
||||
|
||||
### Code and tools
|
||||
|
||||
#### A leaner codebase that builds faster
|
||||
|
||||
Cleaning up Horizon's code: removing what nothing uses and making it quicker to build, so fixes and new features land faster.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
A review of the whole tree: dead code and stubs removed ([#1432](https://github.com/ludovic111/horizon/issues/1432), [#1658](https://github.com/ludovic111/horizon/issues/1658)), one probe binary per crate ([#1453](https://github.com/ludovic111/horizon/issues/1453), [#1454](https://github.com/ludovic111/horizon/issues/1454)), shared harness helpers ([#1448](https://github.com/ludovic111/horizon/issues/1448)), clippy clean on the whole tree ([#1707](https://github.com/ludovic111/horizon/issues/1707)). [docs/architecture.md](docs/architecture.md) maps each feature to its files and harnesses.
|
||||
|
||||
</details>
|
||||
|
||||
## Next
|
||||
|
||||
### Performance
|
||||
|
||||
#### Fresh benchmarks against vanilla
|
||||
|
||||
New public numbers comparing Horizon with vanilla on many kinds of computers, measured the same way every time, on the website's Benchmarks page.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Redo the comparison after the current performance work ([#1073](https://github.com/ludovic111/horizon/issues/1073)); reliability, coverage and presentation ([#1346](https://github.com/ludovic111/horizon/issues/1346)); a scene must count as loaded only once its first chunk arrives ([#1749](https://github.com/ludovic111/horizon/issues/1749)). Players' reports come in through `--benchmark` and `horizon-launcher data benchmark` ([docs/benchmark.md](docs/benchmark.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Modpacks
|
||||
|
||||
#### CurseForge in the mod browser (coming soon)
|
||||
|
||||
Coming soon: search and download CurseForge mods and modpacks from the launcher, as you can with Modrinth today.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The mod browser lists CurseForge as coming soon ([docs/mod-browser.md](docs/mod-browser.md)): it needs CurseForge API access. Meanwhile packs import from a CurseForge zip or instance folder, and jars only CurseForge hosts come from the player's own instance ([docs/modpacks.md](docs/modpacks.md)).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Pistons push players and mobs
|
||||
|
||||
Pistons and slime blocks will push players, mobs and items as in vanilla. Today moving blocks pass through them.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Port `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` (honey), the slime bounce and `Entity.limitPistonMovement` for `MoverType.PISTON`, in single player and for a server's players. Issues [#1496](https://github.com/ludovic111/horizon/issues/1496), [#1609](https://github.com/ludovic111/horizon/issues/1609), [#1608](https://github.com/ludovic111/horizon/issues/1608), [#1704](https://github.com/ludovic111/horizon/issues/1704), [#1758](https://github.com/ludovic111/horizon/issues/1758).
|
||||
|
||||
</details>
|
||||
|
||||
### Launcher
|
||||
|
||||
#### Microsoft sign-in (coming soon)
|
||||
|
||||
Coming soon: sign in with your Microsoft account to play on online-mode servers. The code is ready; it waits for Microsoft's approval.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Device-code sign-in in the launcher and online-mode login in the client ([docs/microsoft-account.md](docs/microsoft-account.md)). It is turned on by setting the app's client id once Minecraft API access is approved; until then the launcher shows "Coming soon".
|
||||
|
||||
More: [#1270](https://github.com/ludovic111/horizon/issues/1270)
|
||||
|
||||
</details>
|
||||
|
||||
#### Horizon on Intel Macs
|
||||
|
||||
Builds of the game and the launcher for Macs with an Intel processor.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
x86_64 game and launcher builds, picked per processor ([#1171](https://github.com/ludovic111/horizon/issues/1171)). Before it ships it needs a run on a real Intel Mac: Rosetta on Apple Silicon only proves the CPU code. Requests: [#1161](https://github.com/ludovic111/horizon/issues/1161), [#1792](https://github.com/ludovic111/horizon/issues/1792).
|
||||
|
||||
</details>
|
||||
|
||||
## Later
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### Following new Minecraft versions
|
||||
|
||||
When Mojang releases a new version of the game, Horizon follows it, and every system is checked against the new version again.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
The port pipeline detects a release, diffs its classes against 26.3 and writes a port report ([docs/version-ports.md](docs/version-ports.md)); the harnesses then run against the new version.
|
||||
|
||||
</details>
|
||||
|
||||
### Horizon extras
|
||||
|
||||
#### An optional AI teammate
|
||||
|
||||
An AI teammate that plays beside you and that you can talk to. It is off unless you turn it on, and the No AI switch hides it completely.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Draft pull requests: the companion ([#1068](https://github.com/ludovic111/horizon/issues/1068)), its safety modes and their evaluation ([#1069](https://github.com/ludovic111/horizon/issues/1069)), and a route that finishes the game ([#1070](https://github.com/ludovic111/horizon/issues/1070)). Local models first; it ships only once the safety evaluation passes.
|
||||
|
||||
</details>
|
||||
|
||||
## Help wanted
|
||||
|
||||
### Performance
|
||||
|
||||
#### Benchmark your computer
|
||||
|
||||
Run Horizon's built-in benchmark and share the report: every new computer makes the performance work more accurate. No coding needed.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`horizon-launcher play --arg=--benchmark --arg=standard` writes a JSON report; `horizon-launcher data benchmark <report>` shows what would be sent and uploads it ([website/docs/performance.md](website/docs/performance.md)). Results from three installs with the same hardware appear on the Benchmarks page. Example: [#1711](https://github.com/ludovic111/horizon/issues/1711).
|
||||
|
||||
</details>
|
||||
|
||||
### Gameplay parity
|
||||
|
||||
#### End city banners keep their pattern
|
||||
|
||||
Banners in End cities show as plain magenta instead of with their pattern.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Compare the banner block entities the End city templates place with vanilla's (`EndCityPieces`, the templates' block entity NBT, `BannerBlockEntity`'s patterns), in `crates/horizon-world/src/worldgen/structure/`. Issue [#1438](https://github.com/ludovic111/horizon/issues/1438); possibly related: [#526](https://github.com/ludovic111/horizon/issues/526).
|
||||
|
||||
</details>
|
||||
|
||||
#### Enchanted loot in End cities
|
||||
|
||||
Armour and tools in End city chests should come enchanted, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`loot.rs` implements `enchant_with_levels`: find why the End city chest table's rolls come out bare, and compare them with vanilla's for a few seeds. Issues [#1437](https://github.com/ludovic111/horizon/issues/1437), [#1660](https://github.com/ludovic111/horizon/issues/1660).
|
||||
|
||||
</details>
|
||||
|
||||
#### Cooldowns drawn on items
|
||||
|
||||
After you throw an ender pearl, its slot should show the cooldown sweeping away, as in vanilla.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla draws `ItemCooldowns`' percent as a white overlay over the slot (`GuiGraphicsExtractor.itemDecorations` and `itemCooldown`) in the hotbar and in inventories. Issue [#1763](https://github.com/ludovic111/horizon/issues/1763).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chat settings and team prefixes
|
||||
|
||||
The Hidden and Commands Only chat settings should hide chat, and team prefixes should appear before names in chat.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla checks `ChatVisiblity` before it delivers chat and system messages ([#1376](https://github.com/ludovic111/horizon/issues/1376)), and chat uses the team-formatted display name ([#1378](https://github.com/ludovic111/horizon/issues/1378)). Both issues have exact steps to reproduce.
|
||||
|
||||
</details>
|
||||
|
||||
#### A pointing hand over buttons
|
||||
|
||||
The mouse pointer should turn into a pointing hand over menu buttons, as in vanilla 26.3.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
26.3's `AbstractWidget` asks for a cursor from `CursorTypes` when it is hovered; winit's `CursorIcon::Pointer` gives the same shape. Issue [#1347](https://github.com/ludovic111/horizon/issues/1347).
|
||||
|
||||
</details>
|
||||
|
||||
#### Ctrl+Backspace and scroll bars in menus
|
||||
|
||||
Ctrl+Backspace should delete a whole word in the creative search, and every list's scroll bar should be draggable.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
`EditBox` deletes by word with Ctrl ([#1764](https://github.com/ludovic111/horizon/issues/1764)); the Game Rules and Statistics lists' scroll bars can't be dragged ([#1782](https://github.com/ludovic111/horizon/issues/1782), after [#978](https://github.com/ludovic111/horizon/issues/978)).
|
||||
|
||||
</details>
|
||||
|
||||
#### Chickens flap while they fall
|
||||
|
||||
Chickens should flap their wings while they fall, not stand still in the air.
|
||||
|
||||
<details><summary>Technical</summary>
|
||||
|
||||
Vanilla's `Chicken.aiStep` updates `flap` and `flapSpeed` when the chicken isn't on the ground, and the model's wings follow them. Issue [#1706](https://github.com/ludovic111/horizon/issues/1706).
|
||||
|
||||
</details>
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **Server resource packs** (v0.2.48): Servers can send you their resource pack: Horizon asks, downloads it and applies it, like vanilla. [#1562](https://github.com/ludovic111/horizon/issues/1562) · [#1569](https://github.com/ludovic111/horizon/issues/1569) · [#1570](https://github.com/ludovic111/horizon/issues/1570)
|
||||
- **Single player on its own server thread** (v0.2.39): Single player runs on a server thread, as vanilla does, so the world keeps ticking smoothly while the window draws. [#1514](https://github.com/ludovic111/horizon/issues/1514) · [docs/server-levels.md](docs/server-levels.md)
|
||||
- **GPU occlusion culling on Macs** (v0.2.17): On Macs the GPU now skips terrain hidden behind hills and caves: 8 to 48% more frames per second at 32 chunks on an M4 Pro. [#1434](https://github.com/ludovic111/horizon/issues/1434)
|
||||
- **Animated textures on the GPU** (v0.2.5): Water, lava and other animated blocks are now animated by the graphics card: 1.7 to 2.4 times better 1% lows on Intel graphics. [#1239](https://github.com/ludovic111/horizon/issues/1239)
|
||||
- **Far Detail for integrated graphics** (v0.2.5): An optional Reduced setting for far terrain, suggested on integrated graphics, that saves 2 to 4 ms of GPU time per frame. [#1199](https://github.com/ludovic111/horizon/issues/1199) · [#1029](https://github.com/ludovic111/horizon/issues/1029)
|
||||
- **The launcher's mod browser** (launcher@0.2.0): Search Modrinth for mods, modpacks and resource packs from the launcher; it says before installing what works in Horizon. [#1154](https://github.com/ludovic111/horizon/issues/1154) · [#1242](https://github.com/ludovic111/horizon/issues/1242) · [#1243](https://github.com/ludovic111/horizon/issues/1243) · [#1246](https://github.com/ludovic111/horizon/issues/1246) · [docs/mod-browser.md](docs/mod-browser.md)
|
||||
- **A blocky, Minecraft-like launcher** (launcher@0.2.0): The launcher got a new blocky look in Horizon's purple, in dark and light. [#1269](https://github.com/ludovic111/horizon/issues/1269) · [docs/design-system.md](docs/design-system.md)
|
||||
- **Dynamic lights** (v2026.10.22): An optional extra: torches you hold, drop or set on fire light up what is around them. [#1162](https://github.com/ludovic111/horizon/issues/1162)
|
||||
- **Host a server from the launcher** (launcher-v0.15.0): The launcher's Servers page creates, starts, backs up and updates a Horizon server with buttons, and invites friends. [#1026](https://github.com/ludovic111/horizon/issues/1026) · [docs/hosting.md](docs/hosting.md)
|
||||
@@ -21,6 +21,8 @@ pub struct Report {
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub signed: bool,
|
||||
/// A mod has code to run (a content mod may have none).
|
||||
pub code: bool,
|
||||
/// A mod's component imports.
|
||||
pub imports: Vec<String>,
|
||||
/// A mod's commands, each run once.
|
||||
@@ -49,6 +51,7 @@ impl Report {
|
||||
"sha256": self.sha256,
|
||||
"size": self.size,
|
||||
"signed": self.signed,
|
||||
"code": self.code,
|
||||
"imports": self.imports,
|
||||
"commands": self.commands,
|
||||
"output": self.output,
|
||||
@@ -87,7 +90,9 @@ impl Report {
|
||||
for r in &f.requires {
|
||||
out += &format!(" needs {} {} ({})\n", r.id, r.version, &r.sha256[..12]);
|
||||
}
|
||||
if self.kind == Kind::Mod {
|
||||
if self.kind == Kind::Mod && !self.code {
|
||||
out += " no code: content only, nothing to run\n";
|
||||
} else if self.kind == Kind::Mod {
|
||||
let commands = if self.commands.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
@@ -142,6 +147,7 @@ pub fn check(
|
||||
sha256: index::sha256_file(path)?,
|
||||
size,
|
||||
signed,
|
||||
code: false,
|
||||
imports: Vec::new(),
|
||||
commands: Vec::new(),
|
||||
output: Vec::new(),
|
||||
@@ -153,10 +159,14 @@ pub fn check(
|
||||
}
|
||||
|
||||
/// Runs a mod in the sandbox, as the game does, from a scratch copy so its
|
||||
/// storage lands in a folder removed afterwards.
|
||||
/// storage lands in a folder removed afterwards. A content mod has no code to
|
||||
/// run: `inspect` already read its content as the game does.
|
||||
fn trial_run(path: &Path, report: &mut Report) -> Result<(), String> {
|
||||
let package = Package::read(path)?;
|
||||
let wasm = package.wasm.as_deref().ok_or("the .hmod has no mod.wasm")?;
|
||||
let Some(wasm) = package.wasm.as_deref() else {
|
||||
return Ok(());
|
||||
};
|
||||
report.code = true;
|
||||
report.imports = component_imports(wasm)?;
|
||||
package
|
||||
.manifest
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
//! `cargo horizon-mod`: make Horizon mods in Rust (`docs/mods.md`).
|
||||
//!
|
||||
//! - `new <path> [--name "Name"] [--sdk-path <dir>]`: a mod crate from the template.
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`.
|
||||
//! - `new <path> [--template code|content] [--name "Name"] [--sdk-path <dir>]`:
|
||||
//! a mod from a template: a Rust crate, or a content mod (no code).
|
||||
//! - `build [--debug]`: the `.hmod` in `target/horizon-mod/`, with the mod's
|
||||
//! `assets/` and `data/`; a folder without `Cargo.toml` is content only.
|
||||
//! - `run [--game <horizon-client>] [-- <game args>]`: build, then play with
|
||||
//! the mod; run `build` again and the game reloads it.
|
||||
//! - `check <file> [--json]`: what the registry checks of a submission
|
||||
//! (`.hmod`, `.hpack`, resource pack `.zip`), with a trial run for a mod.
|
||||
//! - `registry build|review|check|list`: the registry repository's CI.
|
||||
use horizon_mods::{
|
||||
Manifest, Package,
|
||||
Capability, Content, Manifest, Package,
|
||||
manifest::{MANIFEST, component_imports, componentize},
|
||||
};
|
||||
mod check;
|
||||
@@ -36,7 +38,7 @@ fn main() -> ExitCode {
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("check") => check(&args[1..]),
|
||||
Some("registry") => registry::main(&args[1..]),
|
||||
_ => Err("usage: cargo horizon-mod new <path> | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
_ => Err("usage: cargo horizon-mod new <path> [--template code|content] | build [--debug] | run [--game <horizon-client>] [-- <game args>] | check <file> [--kind <kind>] [--key <public key> --signature <file.minisig>] [--json] | registry build|review|check|list".into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -90,19 +92,68 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
.replace("{name}", &name)
|
||||
.replace("{sdk}", &sdk)
|
||||
};
|
||||
let files = [
|
||||
let code = [
|
||||
("Cargo.toml", include_str!("../template/Cargo.toml.in")),
|
||||
(MANIFEST, include_str!("../template/horizon-mod.toml")),
|
||||
("src/lib.rs", include_str!("../template/src/lib.rs")),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
for (file, text) in files {
|
||||
// A block copying a vanilla one, with its model, name, loot, recipe and tag.
|
||||
let content = [
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../template/content/horizon-mod.toml"),
|
||||
),
|
||||
(
|
||||
"data/{id}/horizon/block/glowing_amethyst.json",
|
||||
include_str!("../template/content/block.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/blockstates/glowing_amethyst.json",
|
||||
include_str!("../template/content/blockstate.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/models/block/glowing_amethyst.json",
|
||||
include_str!("../template/content/model.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/items/glowing_amethyst.json",
|
||||
include_str!("../template/content/item.json"),
|
||||
),
|
||||
(
|
||||
"assets/{id}/lang/en_us.json",
|
||||
include_str!("../template/content/lang.json"),
|
||||
),
|
||||
(
|
||||
"data/{id}/loot_table/blocks/glowing_amethyst.json",
|
||||
include_str!("../template/content/loot.json"),
|
||||
),
|
||||
(
|
||||
"data/{id}/recipe/glowing_amethyst.json",
|
||||
include_str!("../template/content/recipe.json"),
|
||||
),
|
||||
(
|
||||
"data/minecraft/tags/block/mineable/pickaxe.json",
|
||||
include_str!("../template/content/pickaxe.json"),
|
||||
),
|
||||
(".gitignore", include_str!("../template/gitignore")),
|
||||
];
|
||||
let files: &[(&str, &str)] = match flag(args, "--template").unwrap_or("code") {
|
||||
"code" => &code,
|
||||
"content" => &content,
|
||||
other => return Err(format!("no template {other}: code or content")),
|
||||
};
|
||||
for &(file, text) in files {
|
||||
let file = fill(file);
|
||||
let file = file.as_str();
|
||||
let to = dir.join(file);
|
||||
std::fs::create_dir_all(to.parent().expect("a file in the mod"))
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(&to, fill(text)).map_err(|e| format!("{}: {e}", to.display()))?;
|
||||
}
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
let manifest =
|
||||
Manifest::parse(&std::fs::read_to_string(dir.join(MANIFEST)).map_err(|e| e.to_string())?)?;
|
||||
Content::read(dir, &manifest)?;
|
||||
println!(
|
||||
"Created {} (mod id {id}). Next: cd {path} && cargo horizon-mod run",
|
||||
dir.display()
|
||||
@@ -110,14 +161,53 @@ fn new(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Builds the crate in the current folder and packs `target/horizon-mod/<id>.hmod`.
|
||||
/// Builds the mod in the current folder and packs `target/horizon-mod/<id>.hmod`:
|
||||
/// the manifest, the crate's component (none without a `Cargo.toml`: a
|
||||
/// content mod) and the files in `assets/` and `data/`.
|
||||
fn build(release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(MANIFEST)
|
||||
build_in(Path::new("."), release)
|
||||
}
|
||||
|
||||
fn build_in(root: &Path, release: bool) -> Result<PathBuf, String> {
|
||||
let manifest_text = std::fs::read_to_string(root.join(MANIFEST))
|
||||
.map_err(|e| format!("{MANIFEST}: {e} (run this in a mod's folder)"))?;
|
||||
let manifest = Manifest::parse(&manifest_text)?;
|
||||
let (wasm, target_dir) = if root.join("Cargo.toml").exists() {
|
||||
let (wasm, target) = compile(root, release)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
(Some(wasm), target)
|
||||
} else if manifest.capabilities.contains(&Capability::Registries) {
|
||||
(None, root.join("target"))
|
||||
} else {
|
||||
return Err(format!(
|
||||
"no Cargo.toml: only a content mod (capability registries in {MANIFEST}) has no code"
|
||||
));
|
||||
};
|
||||
// The same checks as the game's, and the files in the order it lists them.
|
||||
let content = Content::read(root, &manifest)?;
|
||||
let mut reader = content.reader()?;
|
||||
let mut files = Vec::with_capacity(content.files.len());
|
||||
for name in &content.files {
|
||||
files.push((name.as_str(), reader.read(name)?));
|
||||
}
|
||||
let files: Vec<(&str, &[u8])> = files.iter().map(|(n, b)| (*n, b.as_slice())).collect();
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
let bytes = Package::write_files(&manifest_text, wasm.as_deref(), &files)?;
|
||||
std::fs::write(&partial, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Builds the crate for WebAssembly: its component, and Cargo's target folder.
|
||||
fn compile(root: &Path, release: bool) -> Result<(Vec<u8>, PathBuf), String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let mut cmd = Command::new(cargo);
|
||||
cmd.args([
|
||||
cmd.current_dir(root).args([
|
||||
"build",
|
||||
"--lib",
|
||||
"--target",
|
||||
@@ -145,17 +235,8 @@ fn build(release: bool) -> Result<PathBuf, String> {
|
||||
.rfind(|f| f.extension().is_some_and(|e| e == "wasm"))
|
||||
.ok_or("the build made no .wasm: is the crate a cdylib?")?;
|
||||
let wasm = componentize(&std::fs::read(&core).map_err(|e| e.to_string())?)?;
|
||||
// The game refuses a component importing capabilities the manifest lacks.
|
||||
manifest.check_imports(component_imports(&wasm)?.iter().map(String::as_str))?;
|
||||
let target_dir = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
let dir = target_dir.join("horizon-mod");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(format!("{}.hmod", manifest.id));
|
||||
// Write then rename, so a running game never reads half a file.
|
||||
let partial = path.with_extension("hmod.partial");
|
||||
std::fs::write(&partial, Package::write(&manifest_text, &wasm)?).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&partial, &path).map_err(|e| e.to_string())?;
|
||||
Ok(path)
|
||||
let target = core.ancestors().nth(3).ok_or("unexpected target folder")?;
|
||||
Ok((wasm, target.to_path_buf()))
|
||||
}
|
||||
|
||||
/// `check <file>`: the registry's checks of a submission, printed; fails
|
||||
@@ -237,4 +318,49 @@ mod tests {
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_content_template_builds_and_checks_with_no_code() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-mod-content-{}", std::process::id()));
|
||||
let path = dir.join("gems");
|
||||
let new_args = [
|
||||
path.display().to_string(),
|
||||
"--template".into(),
|
||||
"content".into(),
|
||||
];
|
||||
new(&new_args).unwrap();
|
||||
assert!(!path.join("Cargo.toml").exists());
|
||||
let hmod = build_in(&path, true).unwrap();
|
||||
assert_eq!(hmod, path.join("target/horizon-mod/gems.hmod"));
|
||||
let package = Package::read(&hmod).unwrap();
|
||||
assert!(package.wasm.is_none());
|
||||
let content = Content::read(&hmod, &package.manifest).unwrap();
|
||||
assert!(
|
||||
content
|
||||
.files
|
||||
.contains(&"data/gems/horizon/block/glowing_amethyst.json".into())
|
||||
);
|
||||
assert_eq!(content.files.len(), 8, "{:?}", content.files);
|
||||
let report = check::check(&hmod, check::kind_of(&hmod).unwrap(), None, None).unwrap();
|
||||
assert!(!report.code);
|
||||
assert!(
|
||||
report.text().contains("no code: content only"),
|
||||
"{}",
|
||||
report.text()
|
||||
);
|
||||
// Without registries, a mod needs code.
|
||||
let manifest = std::fs::read_to_string(path.join(MANIFEST)).unwrap();
|
||||
let manifest = manifest.replace("[\"registries\"]", "[]");
|
||||
std::fs::write(path.join(MANIFEST), manifest).unwrap();
|
||||
assert!(build_in(&path, true).unwrap_err().contains("no Cargo.toml"));
|
||||
assert!(
|
||||
new(&[
|
||||
dir.join("x").display().to_string(),
|
||||
"--template".into(),
|
||||
"lua".into()
|
||||
])
|
||||
.is_err()
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"template": "minecraft:amethyst_block",
|
||||
"properties": { "hardness": 2.0, "light": 7 }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"variants": {
|
||||
"": { "model": "{id}:block/glowing_amethyst" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
id = "{id}"
|
||||
name = "{name}"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = []
|
||||
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
|
||||
api = "0.2"
|
||||
side = "both"
|
||||
# registries: "May add blocks and items to your worlds".
|
||||
capabilities = ["registries"]
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"model": { "type": "minecraft:model", "model": "{id}:block/glowing_amethyst" }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"block.{id}.glowing_amethyst": "Glowing Amethyst"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "minecraft:block",
|
||||
"pools": [
|
||||
{
|
||||
"condition": { "type": "minecraft:survives_explosion" },
|
||||
"entries": [{ "type": "minecraft:item", "name": "{id}:glowing_amethyst" }],
|
||||
"rolls": 1
|
||||
}
|
||||
],
|
||||
"random_sequence": "{id}:blocks/glowing_amethyst"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"parent": "minecraft:block/cube_all",
|
||||
"textures": { "all": "minecraft:block/amethyst_block" }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"values": ["{id}:glowing_amethyst"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shapeless",
|
||||
"category": "building",
|
||||
"ingredients": ["minecraft:amethyst_block", "minecraft:glowstone_dust"],
|
||||
"result": { "id": "{id}:glowing_amethyst" }
|
||||
}
|
||||
@@ -173,6 +173,11 @@ impl Random {
|
||||
}
|
||||
}
|
||||
|
||||
/// One bot thread that panics poisons the locks it held; the others and the report go on.
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn raw(id: i32, write: impl FnOnce(&mut Writer)) -> Vec<u8> {
|
||||
let mut w = Writer::new();
|
||||
w.varint(id);
|
||||
@@ -329,7 +334,7 @@ fn read(
|
||||
) {
|
||||
use ids::play::clientbound as cb;
|
||||
use ids::play::serverbound as sb;
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body);
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body);
|
||||
let mut seen = HashSet::new();
|
||||
let mut batch = BatchSize::new();
|
||||
let mut teleports = 0u64;
|
||||
@@ -338,7 +343,7 @@ fn read(
|
||||
Ok(packet) => packet,
|
||||
Err(e) => {
|
||||
if bot.online.swap(false, Ordering::SeqCst) {
|
||||
*bot.error.lock().unwrap() = Some(e.to_string());
|
||||
*lock(&bot.error) = Some(e.to_string());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -356,7 +361,7 @@ fn read(
|
||||
cb::LEVEL_CHUNK_WITH_LIGHT => {
|
||||
bot.chunks.fetch_add(1, Ordering::Relaxed);
|
||||
if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) {
|
||||
if !bot.held.lock().unwrap().insert((x, z)) {
|
||||
if !lock(&bot.held).insert((x, z)) {
|
||||
bot.resent.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if seen.insert((x, z)) {
|
||||
@@ -368,7 +373,7 @@ fn read(
|
||||
cb::FORGET_LEVEL_CHUNK => {
|
||||
if let Ok(play::Clientbound::ForgetLevelChunk(p)) =
|
||||
play::Clientbound::decode(id, &body)
|
||||
&& bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1]))
|
||||
&& lock(&bot.held).remove(&(p.pos[0], p.pos[1]))
|
||||
{
|
||||
bot.forgotten.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
@@ -389,7 +394,7 @@ fn read(
|
||||
}
|
||||
cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) {
|
||||
Ok(play::Clientbound::PlayerPosition(p)) => {
|
||||
let mut at = bot.teleport.lock().unwrap();
|
||||
let mut at = lock(&bot.teleport);
|
||||
let previous = at.unwrap_or([0.0; 3]);
|
||||
// Relative flags: X 1, Y 2, Z 4.
|
||||
let pos: [f64; 3] = std::array::from_fn(|i| {
|
||||
@@ -420,7 +425,7 @@ fn read(
|
||||
},
|
||||
cb::DISCONNECT | cb::START_CONFIGURATION => {
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
*bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT {
|
||||
*lock(&bot.error) = Some(if id == cb::DISCONNECT {
|
||||
format!(
|
||||
"disconnected: {}",
|
||||
String::from_utf8_lossy(&body)
|
||||
@@ -455,7 +460,7 @@ fn run_bot(
|
||||
let connection = match join(&o, &name) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
*bot.error.lock().unwrap() = Some(e);
|
||||
*lock(&bot.error) = Some(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -469,12 +474,12 @@ fn run_bot(
|
||||
use ids::play::serverbound as sb;
|
||||
// Wait for the spawn position.
|
||||
let spawn = loop {
|
||||
if let Some(at) = *bot.teleport.lock().unwrap() {
|
||||
if let Some(at) = *lock(&bot.teleport) {
|
||||
break at;
|
||||
}
|
||||
if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) {
|
||||
if bot.error.lock().unwrap().is_none() {
|
||||
*bot.error.lock().unwrap() = Some("no spawn position within 60 s".into());
|
||||
if lock(&bot.error).is_none() {
|
||||
*lock(&bot.error) = Some("no spawn position within 60 s".into());
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -482,7 +487,7 @@ fn run_bot(
|
||||
};
|
||||
bot.joined_after_ms
|
||||
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
|
||||
let send = |body: Vec<u8>| writer.lock().unwrap().send(&body).is_ok();
|
||||
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
|
||||
send(raw(sb::PLAYER_LOADED, |_| {}));
|
||||
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
|
||||
let mut pos = spawn;
|
||||
@@ -491,17 +496,15 @@ fn run_bot(
|
||||
let mut target = pos;
|
||||
let mut next = Instant::now();
|
||||
loop {
|
||||
if until.lock().unwrap().is_some_and(|u| Instant::now() >= u)
|
||||
|| !bot.online.load(Ordering::SeqCst)
|
||||
{
|
||||
if lock(&until).is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
// A teleport from the server wins over where the bot thought it was.
|
||||
if let Some(at) = bot.teleport.lock().unwrap().take() {
|
||||
if let Some(at) = lock(&bot.teleport).take() {
|
||||
pos = at;
|
||||
}
|
||||
let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4);
|
||||
if !bot.held.lock().unwrap().contains(&here) {
|
||||
if !lock(&bot.held).contains(&here) {
|
||||
bot.waited.fetch_add(1, Ordering::Relaxed);
|
||||
} else if pos[1] < cruise {
|
||||
pos[1] = (pos[1] + 0.5).min(cruise);
|
||||
@@ -558,7 +561,7 @@ fn run_bot(
|
||||
}
|
||||
// Offline first, so the reader doesn't count the closed connection as a failure.
|
||||
bot.online.store(false, Ordering::SeqCst);
|
||||
writer.lock().unwrap().shutdown();
|
||||
lock(&writer).shutdown();
|
||||
}
|
||||
|
||||
fn behaviour_of(mode: &str, index: usize) -> Behaviour {
|
||||
@@ -612,13 +615,10 @@ fn main() {
|
||||
let total = |f: fn(&Bot) -> &AtomicU64| -> u64 {
|
||||
bots.iter().map(|b| f(b).load(Ordering::Relaxed)).sum()
|
||||
};
|
||||
let failed = bots
|
||||
.iter()
|
||||
.filter(|b| b.error.lock().unwrap().is_some())
|
||||
.count();
|
||||
let failed = bots.iter().filter(|b| lock(&b.error).is_some()).count();
|
||||
if first.is_none() && online > 0 {
|
||||
first = Some(Instant::now());
|
||||
*until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds));
|
||||
*lock(&until) = Some(Instant::now() + Duration::from_secs(o.seconds));
|
||||
}
|
||||
println!(
|
||||
"{}",
|
||||
@@ -636,10 +636,10 @@ fn main() {
|
||||
})
|
||||
);
|
||||
previous_chunks = chunks;
|
||||
let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u);
|
||||
let over = lock(&until).is_some_and(|u| Instant::now() >= u);
|
||||
let all_failed = failed == o.bots;
|
||||
if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) {
|
||||
*until.lock().unwrap() = Some(Instant::now());
|
||||
*lock(&until) = Some(Instant::now());
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -669,7 +669,7 @@ fn main() {
|
||||
"forgotten": b.forgotten.load(Ordering::Relaxed),
|
||||
"pulled_back": b.pulled_back.load(Ordering::Relaxed),
|
||||
"waited_ticks": b.waited.load(Ordering::Relaxed),
|
||||
"error": *b.error.lock().unwrap(),
|
||||
"error": *lock(&b.error),
|
||||
})).collect::<Vec<_>>(),
|
||||
});
|
||||
if o.report.as_deref() == Some("-") {
|
||||
|
||||
@@ -16,11 +16,13 @@ horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-friends = { path = "../horizon-friends" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Controller",
|
||||
"horizon.options.vibration": "Vibration",
|
||||
"horizon.options.adaptive_triggers": "Adaptive Triggers",
|
||||
"horizon.options.lightbar": "Lightbar",
|
||||
"horizon.options.look_sensitivity": "Look Sensitivity",
|
||||
"horizon.options.invert_x": "Invert Look X",
|
||||
"horizon.options.invert_y": "Invert Look Y",
|
||||
"horizon.options.sneak_button": "Sneak Button",
|
||||
"horizon.options.move_deadzone": "Move Deadzone",
|
||||
"horizon.options.look_deadzone": "Look Deadzone",
|
||||
"horizon.options.button_glyphs": "Button Glyphs",
|
||||
"horizon.options.menu_navigation": "Menu Navigation",
|
||||
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"horizon.options.controller": "Геймпад",
|
||||
"horizon.options.vibration": "Вибрация",
|
||||
"horizon.options.adaptive_triggers": "Адаптивные триггеры",
|
||||
"horizon.options.lightbar": "Подсветка",
|
||||
"horizon.options.look_sensitivity": "Чувствительность обзора",
|
||||
"horizon.options.invert_x": "Инверсия обзора по X",
|
||||
"horizon.options.invert_y": "Инверсия обзора по Y",
|
||||
"horizon.options.sneak_button": "Кнопка приседания",
|
||||
"horizon.options.move_deadzone": "Мёртвая зона движения",
|
||||
"horizon.options.look_deadzone": "Мёртвая зона обзора",
|
||||
"horizon.options.button_glyphs": "Символы кнопок",
|
||||
"horizon.options.menu_navigation": "Навигация по меню",
|
||||
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
//! Discord Rich Presence: "Playing Horizon" on the player's Discord profile, with what they are
|
||||
//! doing (docs/native-client.md). A thread of its own talks to the Discord app on this computer
|
||||
//! through its local IPC socket (Discord's RPC docs: `discord-ipc-N`, frames of opcode u32 LE +
|
||||
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
|
||||
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
|
||||
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
|
||||
//! or with `HORIZON_NO_DISCORD=1`.
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Condvar, Mutex, OnceLock};
|
||||
use std::thread::Thread;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::command::args::GameMode;
|
||||
|
||||
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
|
||||
/// Rich Presence assets: `horizon`, the logo.
|
||||
pub const DISCORD_APP_ID: &str = "1563857609738690560";
|
||||
/// Whether the application has the game-mode images (`survival`, `creative`, `adventure`,
|
||||
/// `spectator`) beside the logo; it doesn't yet.
|
||||
const MODE_IMAGES: bool = false;
|
||||
/// Points the game at a test's fake Discord (a folder holding `discord-ipc-0`).
|
||||
const IPC_DIR_ENV: &str = "HORIZON_DISCORD_IPC_DIR";
|
||||
|
||||
/// Discord drops activity updates beyond 5 in 20 seconds.
|
||||
const BURST: usize = 5;
|
||||
const WINDOW: Duration = Duration::from_secs(20);
|
||||
/// Between two tries to reach Discord while it isn't running.
|
||||
const RETRY: Duration = Duration::from_secs(15);
|
||||
/// The activity is sent again this often, so a Discord restarted meanwhile gets it back.
|
||||
const REFRESH: Duration = Duration::from_secs(60);
|
||||
|
||||
const HANDSHAKE: u32 = 0;
|
||||
const FRAME: u32 = 1;
|
||||
const CLOSE: u32 = 2;
|
||||
const PING: u32 = 3;
|
||||
const PONG: u32 = 4;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Dimension {
|
||||
Overworld,
|
||||
Nether,
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Play {
|
||||
Singleplayer,
|
||||
/// A server or someone else's LAN world. Its address and name are never shown.
|
||||
Multiplayer,
|
||||
/// This world is open to LAN or friends; `players` counts the host.
|
||||
Hosting {
|
||||
players: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Status {
|
||||
/// The player turned Rich Presence off: the activity is cleared.
|
||||
Off,
|
||||
Menus,
|
||||
World {
|
||||
play: Play,
|
||||
dimension: Dimension,
|
||||
mode: GameMode,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the game wants shown, and since when it has been in a world.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Wanted {
|
||||
status: Status,
|
||||
/// Unix seconds: when the player entered the world (dimension changes keep it).
|
||||
since: Option<u64>,
|
||||
}
|
||||
impl Wanted {
|
||||
/// A new status at `now`; false if it is the one already wanted.
|
||||
fn update(&mut self, status: Status, now: u64) -> bool {
|
||||
if self.status == status {
|
||||
return false;
|
||||
}
|
||||
let in_world = |s: Status| matches!(s, Status::World { .. });
|
||||
if !in_world(status) {
|
||||
self.since = None;
|
||||
} else if !in_world(self.status) {
|
||||
self.since = Some(now);
|
||||
}
|
||||
self.status = status;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
struct Shared {
|
||||
wanted: Wanted,
|
||||
stop: bool,
|
||||
stopped: bool,
|
||||
}
|
||||
static SHARED: Mutex<Shared> = Mutex::new(Shared {
|
||||
wanted: Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
},
|
||||
stop: false,
|
||||
stopped: false,
|
||||
});
|
||||
static STOPPED: Condvar = Condvar::new();
|
||||
static THREAD: OnceLock<Thread> = OnceLock::new();
|
||||
|
||||
/// Starts the presence thread, once: when the game starts, never for a dedicated server.
|
||||
/// `automated` runs (offscreen, benchmarks) only talk to a test's fake Discord.
|
||||
pub fn start(enabled: bool, automated: bool) {
|
||||
let fake_discord = std::env::var_os(IPC_DIR_ENV).is_some();
|
||||
if THREAD.get().is_some()
|
||||
|| std::env::var_os("HORIZON_NO_DISCORD").is_some_and(|v| v != "0")
|
||||
|| (automated && !fake_discord)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
lock().wanted.status = Status::Off;
|
||||
}
|
||||
match std::thread::Builder::new()
|
||||
.name("Discord presence".into())
|
||||
.spawn(run)
|
||||
{
|
||||
Ok(handle) => {
|
||||
let _ = THREAD.set(handle.thread().clone());
|
||||
}
|
||||
Err(e) => log::warn!("Horizon: no Discord presence: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the player does now; only a change wakes the thread. Free without [`start`].
|
||||
pub fn set(status: Status) {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
if lock().wanted.update(status, unix_now()) {
|
||||
thread.unpark();
|
||||
}
|
||||
}
|
||||
|
||||
/// On quit: clears the activity, waiting at most a moment for Discord.
|
||||
pub fn stop() {
|
||||
let Some(thread) = THREAD.get() else {
|
||||
return;
|
||||
};
|
||||
let mut shared = lock();
|
||||
shared.stop = true;
|
||||
thread.unpark();
|
||||
let _ = STOPPED.wait_timeout_while(shared, Duration::from_millis(300), |s| !s.stopped);
|
||||
}
|
||||
|
||||
fn lock() -> std::sync::MutexGuard<'static, Shared> {
|
||||
SHARED.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
fn run() {
|
||||
let mut client: Option<Client> = None;
|
||||
let mut sent: Option<(Wanted, Instant)> = None;
|
||||
let mut last_try: Option<Instant> = None;
|
||||
let mut limit = RateLimit::default();
|
||||
loop {
|
||||
let (wanted, stop) = {
|
||||
let s = lock();
|
||||
(s.wanted, s.stop)
|
||||
};
|
||||
if stop || wanted.status == Status::Off {
|
||||
if let Some(mut c) = client.take() {
|
||||
let _ = c.set_activity(None);
|
||||
c.close();
|
||||
}
|
||||
sent = None;
|
||||
if stop {
|
||||
lock().stopped = true;
|
||||
STOPPED.notify_all();
|
||||
return;
|
||||
}
|
||||
std::thread::park();
|
||||
continue;
|
||||
}
|
||||
let now = Instant::now();
|
||||
if client.is_none() {
|
||||
if let Some(wait) = last_try.and_then(|t| RETRY.checked_sub(now - t)) {
|
||||
std::thread::park_timeout(wait);
|
||||
continue;
|
||||
}
|
||||
last_try = Some(now);
|
||||
client = Client::connect();
|
||||
if client.is_none() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let due = sent.is_none_or(|(w, at)| w != wanted || now - at >= REFRESH);
|
||||
if !due {
|
||||
let (_, at) = sent.expect("sent");
|
||||
std::thread::park_timeout(REFRESH - (now - at));
|
||||
continue;
|
||||
}
|
||||
let wait = limit.wait(now);
|
||||
if !wait.is_zero() {
|
||||
std::thread::park_timeout(wait);
|
||||
continue;
|
||||
}
|
||||
limit.record(now);
|
||||
let c = client.as_mut().expect("connected");
|
||||
match c.set_activity(activity(wanted.status, wanted.since)) {
|
||||
Ok(()) => sent = Some((wanted, now)),
|
||||
Err(e) => {
|
||||
log::info!("Horizon: Discord presence disconnected ({e})");
|
||||
client = None;
|
||||
sent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The activity Discord shows for `status`; `None` clears it.
|
||||
fn activity(status: Status, since: Option<u64>) -> Option<Value> {
|
||||
let mut assets = json!({"large_image": "horizon", "large_text": "Horizon"});
|
||||
let (details, state, party) = match status {
|
||||
Status::Off => return None,
|
||||
Status::Menus => ("In the menus", None, None),
|
||||
Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode,
|
||||
} => {
|
||||
if MODE_IMAGES {
|
||||
let (key, text) = match mode {
|
||||
GameMode::Survival => ("survival", "Survival"),
|
||||
GameMode::Creative => ("creative", "Creative"),
|
||||
GameMode::Adventure => ("adventure", "Adventure"),
|
||||
GameMode::Spectator => ("spectator", "Spectator"),
|
||||
};
|
||||
assets["small_image"] = key.into();
|
||||
assets["small_text"] = text.into();
|
||||
}
|
||||
let place = match dimension {
|
||||
Dimension::Overworld => "In the Overworld",
|
||||
Dimension::Nether => "In the Nether",
|
||||
Dimension::End => "In the End",
|
||||
};
|
||||
let (details, party) = match play {
|
||||
Play::Singleplayer => ("Singleplayer", None),
|
||||
Play::Multiplayer => ("Multiplayer", None),
|
||||
Play::Hosting { players } => ("Hosting a world", Some(players)),
|
||||
};
|
||||
(details, Some(place), party)
|
||||
}
|
||||
};
|
||||
let mut a = json!({"details": details, "assets": assets});
|
||||
if let Some(state) = state {
|
||||
a["state"] = state.into();
|
||||
}
|
||||
if let Some(start) = since {
|
||||
a["timestamps"] = json!({"start": start});
|
||||
}
|
||||
if let Some(players) = party {
|
||||
let max = crate::lan::MAX_PLAYERS;
|
||||
a["party"] = json!({
|
||||
"id": format!("horizon-{}", std::process::id()),
|
||||
"size": [players.clamp(1, max), max],
|
||||
});
|
||||
}
|
||||
Some(a)
|
||||
}
|
||||
|
||||
/// At most [`BURST`] updates in any [`WINDOW`].
|
||||
#[derive(Default)]
|
||||
struct RateLimit {
|
||||
sent: VecDeque<Instant>,
|
||||
}
|
||||
impl RateLimit {
|
||||
/// How long until the next update may go.
|
||||
fn wait(&self, now: Instant) -> Duration {
|
||||
if self.sent.len() < BURST {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
(self.sent[0] + WINDOW).saturating_duration_since(now)
|
||||
}
|
||||
fn record(&mut self, now: Instant) {
|
||||
if self.sent.len() == BURST {
|
||||
self.sent.pop_front();
|
||||
}
|
||||
self.sent.push_back(now);
|
||||
}
|
||||
}
|
||||
|
||||
fn encode(op: u32, payload: &Value) -> Vec<u8> {
|
||||
let body = payload.to_string();
|
||||
let mut frame = Vec::with_capacity(8 + body.len());
|
||||
frame.extend_from_slice(&op.to_le_bytes());
|
||||
frame.extend_from_slice(&(body.len() as u32).to_le_bytes());
|
||||
frame.extend_from_slice(body.as_bytes());
|
||||
frame
|
||||
}
|
||||
|
||||
fn read_frame(r: &mut impl Read) -> io::Result<(u32, Value)> {
|
||||
let mut header = [0; 8];
|
||||
r.read_exact(&mut header)?;
|
||||
let op = u32::from_le_bytes(header[..4].try_into().expect("4 bytes"));
|
||||
let len = u32::from_le_bytes(header[4..].try_into().expect("4 bytes")) as usize;
|
||||
if len > 1 << 20 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"frame too large",
|
||||
));
|
||||
}
|
||||
let mut body = vec![0; len];
|
||||
r.read_exact(&mut body)?;
|
||||
Ok((op, serde_json::from_slice(&body)?))
|
||||
}
|
||||
|
||||
trait Pipe: Read + Write + Send {}
|
||||
impl<T: Read + Write + Send> Pipe for T {}
|
||||
|
||||
struct Client {
|
||||
pipe: Box<dyn Pipe>,
|
||||
nonce: u64,
|
||||
}
|
||||
impl Client {
|
||||
/// The first Discord socket that answers the handshake with READY.
|
||||
fn connect() -> Option<Self> {
|
||||
sockets().into_iter().find_map(|path| {
|
||||
let mut client = Client {
|
||||
pipe: open(&path).ok()?,
|
||||
nonce: 0,
|
||||
};
|
||||
match client.handshake() {
|
||||
Ok(()) => {
|
||||
log::info!("Horizon: connected to Discord for Rich Presence");
|
||||
Some(client)
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Horizon: Discord at {} refused: {e}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn handshake(&mut self) -> io::Result<()> {
|
||||
let hello = json!({"v": 1, "client_id": DISCORD_APP_ID});
|
||||
self.pipe.write_all(&encode(HANDSHAKE, &hello))?;
|
||||
self.reply(|frame| frame["evt"] == "READY")
|
||||
}
|
||||
fn set_activity(&mut self, activity: Option<Value>) -> io::Result<()> {
|
||||
self.nonce += 1;
|
||||
let nonce = self.nonce.to_string();
|
||||
let command = json!({
|
||||
"cmd": "SET_ACTIVITY",
|
||||
"args": {"pid": std::process::id(), "activity": activity},
|
||||
"nonce": nonce,
|
||||
});
|
||||
self.pipe.write_all(&encode(FRAME, &command))?;
|
||||
self.reply(|frame| frame["nonce"] == nonce.as_str())
|
||||
}
|
||||
/// Reads until the frame `ours` picks, answering pings; an ERROR is logged, not fatal.
|
||||
fn reply(&mut self, ours: impl Fn(&Value) -> bool) -> io::Result<()> {
|
||||
loop {
|
||||
match read_frame(&mut self.pipe)? {
|
||||
(FRAME, frame) if ours(&frame) => {
|
||||
if frame["evt"] == "ERROR" {
|
||||
log::info!("Horizon: Discord presence: {}", frame["data"]["message"]);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
(PING, frame) => self.pipe.write_all(&encode(PONG, &frame))?,
|
||||
(CLOSE, frame) => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionAborted,
|
||||
frame["message"].as_str().unwrap_or("closed").to_owned(),
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(mut self) {
|
||||
let _ = self.pipe.write_all(&encode(CLOSE, &json!({})));
|
||||
}
|
||||
}
|
||||
|
||||
/// Where Discord listens: `discord-ipc-0..9` in the temporary folders (and Flatpak's and
|
||||
/// Snap's subfolders) on Linux and macOS, named pipes on Windows.
|
||||
fn sockets() -> Vec<PathBuf> {
|
||||
sockets_with(|key| std::env::var_os(key), cfg!(windows))
|
||||
}
|
||||
|
||||
fn sockets_with(var: impl Fn(&str) -> Option<std::ffi::OsString>, windows: bool) -> Vec<PathBuf> {
|
||||
let names = (0..10).map(|n| format!("discord-ipc-{n}"));
|
||||
if let Some(dir) = var(IPC_DIR_ENV) {
|
||||
return names.map(|n| PathBuf::from(&dir).join(n)).collect();
|
||||
}
|
||||
if windows {
|
||||
return names.map(|n| PathBuf::from(r"\\?\pipe\").join(n)).collect();
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = ["XDG_RUNTIME_DIR", "TMPDIR", "TMP", "TEMP"]
|
||||
.into_iter()
|
||||
.filter_map(&var)
|
||||
.map(PathBuf::from)
|
||||
.collect();
|
||||
dirs.push("/tmp".into());
|
||||
dirs.dedup();
|
||||
let mut paths = Vec::new();
|
||||
for dir in dirs {
|
||||
for sub in ["", "app/com.discordapp.Discord", "snap.discord"] {
|
||||
paths.extend(names.clone().map(|n| dir.join(sub).join(n)));
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
|
||||
let stream = std::os::unix::net::UnixStream::connect(path)?;
|
||||
// A Discord that stops answering costs this thread a few seconds, never the game.
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
||||
Ok(Box::new(stream))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn open(path: &std::path::Path) -> io::Result<Box<dyn Pipe>> {
|
||||
let pipe = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(path)?;
|
||||
Ok(Box::new(pipe))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn world(play: Play, dimension: Dimension) -> Status {
|
||||
Status::World {
|
||||
play,
|
||||
dimension,
|
||||
mode: GameMode::Survival,
|
||||
}
|
||||
}
|
||||
|
||||
/// The folders a real Discord listens in: `$TMPDIR` on macOS, `$XDG_RUNTIME_DIR` on Linux (and its
|
||||
/// Flatpak and Snap subfolders), named pipes on Windows.
|
||||
#[test]
|
||||
fn sockets_cover_where_discord_listens() {
|
||||
let env = |pairs: &'static [(&'static str, &'static str)]| {
|
||||
move |key: &str| {
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(k, _)| *k == key)
|
||||
.map(|(_, v)| std::ffi::OsString::from(v))
|
||||
}
|
||||
};
|
||||
let macos = sockets_with(env(&[("TMPDIR", "/var/folders/xy/abc/T/")]), false);
|
||||
assert_eq!(
|
||||
macos[0],
|
||||
PathBuf::from("/var/folders/xy/abc/T/discord-ipc-0")
|
||||
);
|
||||
assert!(macos.contains(&PathBuf::from("/var/folders/xy/abc/T/discord-ipc-9")));
|
||||
assert!(macos.contains(&PathBuf::from("/tmp/discord-ipc-0")));
|
||||
|
||||
let linux = sockets_with(
|
||||
env(&[("XDG_RUNTIME_DIR", "/run/user/1000"), ("TMPDIR", "/tmp")]),
|
||||
false,
|
||||
);
|
||||
assert_eq!(linux[0], PathBuf::from("/run/user/1000/discord-ipc-0"));
|
||||
for path in [
|
||||
"/run/user/1000/app/com.discordapp.Discord/discord-ipc-0",
|
||||
"/run/user/1000/snap.discord/discord-ipc-0",
|
||||
"/tmp/discord-ipc-0",
|
||||
] {
|
||||
assert!(linux.contains(&PathBuf::from(path)), "{path}");
|
||||
}
|
||||
assert_eq!(linux.len(), 2 * 3 * 10, "/tmp is listed once");
|
||||
|
||||
let bare = sockets_with(env(&[]), false);
|
||||
assert_eq!(bare[0], PathBuf::from("/tmp/discord-ipc-0"));
|
||||
|
||||
let windows = sockets_with(env(&[("TEMP", r"C:\Temp")]), true);
|
||||
assert_eq!(windows.len(), 10);
|
||||
// `Path::join` only uses `\` as the separator on Windows itself.
|
||||
#[cfg(windows)]
|
||||
assert_eq!(windows[0], PathBuf::from(r"\\?\pipe\discord-ipc-0"));
|
||||
|
||||
let fake = sockets_with(
|
||||
env(&[("HORIZON_DISCORD_IPC_DIR", "/tmp/hz"), ("TMPDIR", "/x")]),
|
||||
false,
|
||||
);
|
||||
assert_eq!(fake.len(), 10);
|
||||
assert_eq!(fake[0], PathBuf::from("/tmp/hz/discord-ipc-0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_round_trip() {
|
||||
let frame = encode(FRAME, &json!({"cmd": "SET_ACTIVITY"}));
|
||||
assert_eq!(&frame[..4], &[1, 0, 0, 0]);
|
||||
assert_eq!(frame[4..8], (frame.len() as u32 - 8).to_le_bytes());
|
||||
let (op, value) = read_frame(&mut frame.as_slice()).unwrap();
|
||||
assert_eq!((op, value), (FRAME, json!({"cmd": "SET_ACTIVITY"})));
|
||||
assert!(read_frame(&mut &frame[..6]).is_err());
|
||||
let huge = [1, 0, 0, 0, 0, 0, 0, 2];
|
||||
assert!(read_frame(&mut huge.as_slice()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activities_say_what_the_player_does_and_nothing_private() {
|
||||
assert_eq!(activity(Status::Off, None), None);
|
||||
let menus = activity(Status::Menus, None).unwrap();
|
||||
assert_eq!(menus["details"], "In the menus");
|
||||
assert!(menus.get("state").is_none() && menus.get("timestamps").is_none());
|
||||
assert_eq!(menus["assets"]["large_image"], "horizon");
|
||||
|
||||
let single = activity(world(Play::Singleplayer, Dimension::Nether), Some(1000)).unwrap();
|
||||
assert_eq!(single["details"], "Singleplayer");
|
||||
assert_eq!(single["state"], "In the Nether");
|
||||
assert_eq!(single["timestamps"]["start"], 1000);
|
||||
assert!(single.get("party").is_none());
|
||||
assert!(single["assets"].get("small_image").is_none());
|
||||
|
||||
let multi = activity(world(Play::Multiplayer, Dimension::End), Some(5)).unwrap();
|
||||
assert_eq!(multi["details"], "Multiplayer");
|
||||
assert_eq!(multi["state"], "In the End");
|
||||
|
||||
let host = world(Play::Hosting { players: 3 }, Dimension::Overworld);
|
||||
let host = activity(host, Some(5)).unwrap();
|
||||
assert_eq!(host["details"], "Hosting a world");
|
||||
assert_eq!(host["state"], "In the Overworld");
|
||||
assert_eq!(host["party"]["size"], json!([3, 8]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_counts_from_entering_the_world() {
|
||||
let mut w = Wanted {
|
||||
status: Status::Menus,
|
||||
since: None,
|
||||
};
|
||||
assert!(!w.update(Status::Menus, 10));
|
||||
assert!(w.update(world(Play::Singleplayer, Dimension::Overworld), 20));
|
||||
assert_eq!(w.since, Some(20));
|
||||
// Another dimension, opening to LAN: the same visit.
|
||||
assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30));
|
||||
assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40));
|
||||
assert_eq!(w.since, Some(20));
|
||||
assert!(w.update(Status::Menus, 50));
|
||||
assert_eq!(w.since, None);
|
||||
assert!(w.update(world(Play::Multiplayer, Dimension::Overworld), 60));
|
||||
assert_eq!(w.since, Some(60));
|
||||
assert!(w.update(Status::Off, 70));
|
||||
assert_eq!(w.since, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn at_most_five_updates_in_twenty_seconds() {
|
||||
let t = Instant::now();
|
||||
let mut limit = RateLimit::default();
|
||||
for i in 0..5 {
|
||||
let now = t + Duration::from_secs(i);
|
||||
assert_eq!(limit.wait(now), Duration::ZERO);
|
||||
limit.record(now);
|
||||
}
|
||||
assert_eq!(
|
||||
limit.wait(t + Duration::from_secs(5)),
|
||||
Duration::from_secs(15)
|
||||
);
|
||||
assert_eq!(limit.wait(t + WINDOW), Duration::ZERO);
|
||||
limit.record(t + WINDOW);
|
||||
assert_eq!(limit.wait(t + WINDOW), Duration::from_secs(1));
|
||||
}
|
||||
|
||||
/// A fake Discord: answers the handshake with READY, each command with its nonce, after a ping.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn talks_to_discord_over_its_socket() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-discord-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("discord-ipc-0");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let listener = std::os::unix::net::UnixListener::bind(&path).unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let (op, hello) = read_frame(&mut s).unwrap();
|
||||
assert_eq!((op, &hello["v"]), (HANDSHAKE, &json!(1)));
|
||||
let ready = json!({"cmd": "DISPATCH", "evt": "READY", "data": {"v": 1}});
|
||||
s.write_all(&encode(FRAME, &ready)).unwrap();
|
||||
let mut got = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let (op, cmd) = read_frame(&mut s).unwrap();
|
||||
assert_eq!(op, FRAME);
|
||||
s.write_all(&encode(PING, &json!({"n": 1}))).unwrap();
|
||||
assert_eq!(read_frame(&mut s).unwrap(), (PONG, json!({"n": 1})));
|
||||
let reply = json!({"cmd": "SET_ACTIVITY", "nonce": cmd["nonce"], "data": null});
|
||||
s.write_all(&encode(FRAME, &reply)).unwrap();
|
||||
got.push(cmd);
|
||||
}
|
||||
got
|
||||
});
|
||||
let mut client = Client {
|
||||
pipe: open(&path).unwrap(),
|
||||
nonce: 0,
|
||||
};
|
||||
client.handshake().unwrap();
|
||||
let shown = activity(world(Play::Singleplayer, Dimension::End), Some(7));
|
||||
client.set_activity(shown.clone()).unwrap();
|
||||
client.set_activity(None).unwrap();
|
||||
let got = server.join().unwrap();
|
||||
assert_eq!(got[0]["cmd"], "SET_ACTIVITY");
|
||||
assert_eq!(got[0]["args"]["pid"], std::process::id());
|
||||
assert_eq!(got[0]["args"]["activity"], shown.unwrap());
|
||||
assert_eq!(got[1]["args"]["activity"], Value::Null);
|
||||
assert_ne!(got[0]["nonce"], got[1]["nonce"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
@@ -1155,6 +1155,72 @@ pub fn arrow_from_nbt(
|
||||
Some((p, tag.get("inBlockState").cloned()))
|
||||
}
|
||||
|
||||
/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s
|
||||
/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`.
|
||||
pub fn fireball_to_nbt(
|
||||
p: &Projectile,
|
||||
uuid: [i32; 4],
|
||||
owner: Option<[i32; 4]>,
|
||||
raw: Option<&Tag>,
|
||||
) -> Option<Tag> {
|
||||
let power = match p.kind {
|
||||
ProjectileKind::LargeFireball { power } => Some(power),
|
||||
ProjectileKind::SmallFireball => None,
|
||||
_ => return None,
|
||||
};
|
||||
let base = Base {
|
||||
id: p.kind.id(),
|
||||
uuid,
|
||||
position: p.position,
|
||||
motion: p.velocity,
|
||||
rotation: [p.yaw, p.pitch],
|
||||
on_ground: false,
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
put(
|
||||
&mut tag,
|
||||
"acceleration_power",
|
||||
Tag::Double(p.acceleration.to_bits()),
|
||||
);
|
||||
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
|
||||
// (a fireball saved before its first tick keeps what it was loaded with).
|
||||
if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"LeftOwner".into());
|
||||
}
|
||||
if p.left_owner {
|
||||
put(&mut tag, "LeftOwner", Tag::Byte(1));
|
||||
}
|
||||
if p.age > 0 || tag.get("HasBeenShot").is_none() {
|
||||
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
|
||||
}
|
||||
if let Some(power) = power {
|
||||
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
|
||||
}
|
||||
if let Some(owner) = owner {
|
||||
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
|
||||
}
|
||||
Some(tag)
|
||||
}
|
||||
|
||||
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
|
||||
let kind = match kind(tag)?.as_str() {
|
||||
"minecraft:fireball" => ProjectileKind::LargeFireball {
|
||||
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
|
||||
},
|
||||
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
|
||||
_ => return None,
|
||||
};
|
||||
let owner = uuid_from_tag(tag.get("Owner"));
|
||||
let from_player = player.is_some() && owner == player;
|
||||
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
|
||||
p.velocity = motion(tag);
|
||||
[p.yaw, p.pitch] = rotation(tag);
|
||||
p.left_owner = flag(tag, "LeftOwner");
|
||||
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn falling_block_to_nbt(
|
||||
@@ -2139,3 +2205,41 @@ mod sheep_nbt_tests {
|
||||
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fireball_nbt_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn a_fireball_round_trips_its_flight() {
|
||||
let raw = Tag::compound([
|
||||
("id", Tag::String("minecraft:fireball".into())),
|
||||
("Pos", doubles(&[1.0, 70.0, -2.5])),
|
||||
("Motion", doubles(&[0.0, -0.5, 1.0])),
|
||||
("ExplosionPower", Tag::Byte(4)),
|
||||
("acceleration_power", Tag::Double(0.0f64.to_bits())),
|
||||
]);
|
||||
let p = fireball_from_nbt(&raw, None).unwrap();
|
||||
assert_eq!(p.kind, ProjectileKind::LargeFireball { power: 4 });
|
||||
assert_eq!((p.velocity.z, p.acceleration), (1.0, 0.0));
|
||||
let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, None).unwrap();
|
||||
assert_eq!(number(back.get("ExplosionPower")), Some(4));
|
||||
assert_eq!(float(back.get("acceleration_power")), Some(0.0));
|
||||
// Projectile.addAdditionalSaveData: no LeftOwner unless set, HasBeenShot always.
|
||||
assert!(back.get("LeftOwner").is_none());
|
||||
assert_eq!(number(back.get("HasBeenShot")), Some(0));
|
||||
assert_eq!(
|
||||
read_doubles::<3>(back.get("Motion")),
|
||||
Some([0.0, -0.5, 1.0])
|
||||
);
|
||||
// A default fireball keeps vanilla's power and acceleration.
|
||||
let plain = Tag::compound([
|
||||
("id", Tag::String("minecraft:fireball".into())),
|
||||
("Pos", doubles(&[0.0, 0.0, 0.0])),
|
||||
]);
|
||||
let p = fireball_from_nbt(&plain, None).unwrap();
|
||||
assert_eq!(
|
||||
(p.kind, p.acceleration),
|
||||
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Horizon's optional extras (zoom, FPS counter, keystroke overlay): their
|
||||
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
|
||||
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
|
||||
//! default; see `docs/extras.md`.
|
||||
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
|
||||
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
|
||||
use std::{collections::VecDeque, fs, path::Path};
|
||||
|
||||
pub const FILE: &str = "horizon-extras.txt";
|
||||
@@ -112,6 +113,8 @@ pub struct Extras {
|
||||
/// Agents may control the game the player starts: it opens its control socket for
|
||||
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
|
||||
pub agent_control: bool,
|
||||
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
|
||||
pub discord_presence: bool,
|
||||
}
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
@@ -142,6 +145,7 @@ impl Default for Extras {
|
||||
sort_key: "key.keyboard.unknown".into(),
|
||||
no_ai: false,
|
||||
agent_control: false,
|
||||
discord_presence: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,6 +211,7 @@ impl Extras {
|
||||
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
|
||||
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
|
||||
"agentControl" => e.agent_control = flag(e.agent_control),
|
||||
"discordPresence" => e.discord_presence = flag(e.discord_presence),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +219,7 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -246,6 +251,7 @@ impl Extras {
|
||||
horizon_core::no_ai::KEY,
|
||||
bool_text(self.no_ai),
|
||||
bool_text(self.agent_control),
|
||||
bool_text(self.discord_presence),
|
||||
)
|
||||
}
|
||||
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
|
||||
@@ -466,6 +472,8 @@ pub enum Item {
|
||||
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
|
||||
NoAi,
|
||||
AgentControl,
|
||||
/// On the Horizon Settings page.
|
||||
DiscordPresence,
|
||||
}
|
||||
/// The Extras page: headers and items.
|
||||
pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
@@ -546,6 +554,7 @@ impl Item {
|
||||
Item::KeystrokesCps => "Clicks per second",
|
||||
Item::NoAi => "No AI",
|
||||
Item::AgentControl => "Agent Control",
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
}
|
||||
}
|
||||
/// The switch this setting belongs to: it does nothing while that is off.
|
||||
@@ -559,7 +568,7 @@ impl Item {
|
||||
MouseTweaksWheel => MouseTweaks,
|
||||
SortKey => Sort,
|
||||
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl => {
|
||||
| AutoReconnect | NoAi | AgentControl | DiscordPresence => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -606,6 +615,9 @@ impl Item {
|
||||
Item::NoAi => {
|
||||
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
|
||||
}
|
||||
Item::DiscordPresence => {
|
||||
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
|
||||
}
|
||||
Item::AgentControl => {
|
||||
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
|
||||
}
|
||||
@@ -641,6 +653,7 @@ impl Item {
|
||||
Item::Sort => e.sort,
|
||||
Item::NoAi => e.no_ai,
|
||||
Item::AgentControl => e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -698,6 +711,7 @@ impl Item {
|
||||
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
|
||||
Item::NoAi => e.no_ai = !e.no_ai,
|
||||
Item::AgentControl => e.agent_control = !e.agent_control,
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::ZoomKey | Item::SortKey => {}
|
||||
}
|
||||
}
|
||||
@@ -721,6 +735,7 @@ mod tests {
|
||||
assert!(!d.fullbright && !d.dynamic_lights);
|
||||
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
|
||||
assert!(!d.mouse_tweaks && !d.sort);
|
||||
assert!(d.discord_presence);
|
||||
assert_eq!(Extras::parse(""), d);
|
||||
assert_eq!(Extras::parse(&d.serialize()), d);
|
||||
let mut e = d.clone();
|
||||
@@ -746,6 +761,7 @@ mod tests {
|
||||
e.mouse_tweaks_wheel = false;
|
||||
e.sort = true;
|
||||
e.sort_key = "key.keyboard.r".into();
|
||||
e.discord_presence = false;
|
||||
assert_eq!(Extras::parse(&e.serialize()), e);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ mod debug_keys;
|
||||
mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
mod dragon;
|
||||
@@ -141,6 +142,7 @@ mod sculk;
|
||||
mod sculk_fixture;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod shield;
|
||||
mod sign_edit;
|
||||
mod sniffers;
|
||||
@@ -9562,13 +9564,13 @@ impl Game<'_> {
|
||||
let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0);
|
||||
let stack = self.inventory[c].clone();
|
||||
self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y);
|
||||
if let Some(stack) = stack
|
||||
&& self.cursor.0 >= x
|
||||
if self.cursor.0 >= x
|
||||
&& self.cursor.0 < x + 16.0
|
||||
&& self.cursor.1 >= y
|
||||
&& self.cursor.1 < y + 16.0
|
||||
{
|
||||
hovered = Some(stack);
|
||||
batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128));
|
||||
hovered = stack;
|
||||
}
|
||||
}
|
||||
let scrollable = self.creative_screen.rows(&self.pack.names) > 5;
|
||||
@@ -10085,6 +10087,11 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.gather_dynamic_lights(camera, alpha);
|
||||
}
|
||||
let pack_inputs = if title {
|
||||
Default::default()
|
||||
} else {
|
||||
self.shader_pack_inputs(camera, alpha as f32)
|
||||
};
|
||||
let frame = Frame {
|
||||
weather: &weather,
|
||||
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
|
||||
@@ -10168,6 +10175,7 @@ impl Game<'_> {
|
||||
hand: &hand,
|
||||
hand_fov_degrees: self.hud_fov_degrees(camera_alpha as f32, camera.to_array()),
|
||||
screen: &screen,
|
||||
pack: pack_inputs,
|
||||
border,
|
||||
game_time: self.world.game_time as f32,
|
||||
shader_game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0,
|
||||
@@ -11418,6 +11426,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
self.track_toggle_screens();
|
||||
// For the "I'm playing" ping: an atomic, sent on when it changes.
|
||||
horizon_client::heartbeat::set_lan(self.is_published());
|
||||
self.update_discord_presence();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
// The world ticking in the frame: once its ticks took a
|
||||
// tick's length, the rest are dropped (`FixedStep::tick_fits`).
|
||||
@@ -12089,6 +12098,8 @@ enum PendingWorldEntry {
|
||||
Server {
|
||||
address: String,
|
||||
name: String,
|
||||
/// A transfer's carried-over server cookies (`TransferState`).
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12163,8 +12174,12 @@ impl Game<'_> {
|
||||
}) => {
|
||||
self.open_world(&folder, name.as_deref(), seed, creative, create.as_deref())?;
|
||||
}
|
||||
Some(PendingWorldEntry::Server { address, name }) => {
|
||||
self.connect_server(&address, &name)?;
|
||||
Some(PendingWorldEntry::Server {
|
||||
address,
|
||||
name,
|
||||
cookies,
|
||||
}) => {
|
||||
self.connect_server_with_cookies(&address, &name, cookies)?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
@@ -12716,6 +12731,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
hand: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
border: None,
|
||||
game_time: 0.0,
|
||||
shader_game_time: 0.0,
|
||||
@@ -12888,6 +12904,7 @@ fn write_report(game: &Game<'_>) -> Result<()> {
|
||||
|
||||
fn run_event_loop(mut game: Game<'_>) -> Result<()> {
|
||||
EventLoop::new()?.run_app(&mut game)?;
|
||||
horizon_client::discord_presence::stop();
|
||||
write_report(&game)?;
|
||||
if let Some(e) = game.error.take() {
|
||||
return Err(e);
|
||||
@@ -13245,6 +13262,7 @@ fn run_loading(
|
||||
};
|
||||
let mut app = App::Loading(Box::new(loading));
|
||||
EventLoop::new()?.run_app(&mut app)?;
|
||||
horizon_client::discord_presence::stop();
|
||||
match app {
|
||||
App::Ready(mut game) => {
|
||||
write_report(&game)?;
|
||||
@@ -13295,6 +13313,14 @@ pub fn run() -> Result<()> {
|
||||
horizon_client::spans::enable(options.spikes.is_some());
|
||||
let vanilla_root = options.resources.clone();
|
||||
let early_settings = Settings::load(&std::env::current_dir()?.join("options.txt"));
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
if options.dedicated.is_none() {
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
horizon_client::discord_presence::start(early_settings.extras.discord_presence, automated);
|
||||
}
|
||||
// Snapshots of derived tables live in the game folder, never beside the
|
||||
// shared resources.
|
||||
Behaviours::set_cache_dir(std::env::current_dir()?.join(".horizon-cache"));
|
||||
|
||||
@@ -469,7 +469,8 @@ impl Game<'_> {
|
||||
"flying": p.flying,
|
||||
"fall_flying": p.fall_flying,
|
||||
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
|
||||
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
|
||||
"sprinting": p.sprinting,
|
||||
"fov_modifier": self.view.fov_modifier,
|
||||
"health": p.health.health(),
|
||||
"absorption": p.health.absorption(),
|
||||
// The hurt animation's ticks left (the camera's hurt tilt).
|
||||
|
||||
@@ -42,7 +42,8 @@ pub(super) enum Target {
|
||||
Mob(u32),
|
||||
Item(u32),
|
||||
Orb(usize),
|
||||
Arrow(usize),
|
||||
/// By index in `creatures.projectiles`.
|
||||
Projectile(usize),
|
||||
Stand(u32),
|
||||
Frame(u32),
|
||||
Painting(u32),
|
||||
@@ -327,7 +328,7 @@ impl Game<'_> {
|
||||
.orbs
|
||||
.get(i)
|
||||
.map(|o| [o.position.x, o.position.y, o.position.z]),
|
||||
Target::Arrow(i) => self
|
||||
Target::Projectile(i) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
@@ -382,7 +383,11 @@ impl Game<'_> {
|
||||
}),
|
||||
Target::Item(_) => "minecraft:item".into(),
|
||||
Target::Orb(_) => "minecraft:experience_orb".into(),
|
||||
Target::Arrow(_) => "minecraft:arrow".into(),
|
||||
Target::Projectile(i) => self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get(i)
|
||||
.map_or_else(String::new, |p| p.kind.id().into()),
|
||||
Target::Stand(_) => "minecraft:armor_stand".into(),
|
||||
Target::Frame(id) => {
|
||||
if self.item_frames.iter().any(|f| f.id == id && f.glow) {
|
||||
@@ -503,6 +508,7 @@ impl Game<'_> {
|
||||
_ => {}
|
||||
}
|
||||
if s.kind != 's' {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
candidates.extend(self.player_candidates(s));
|
||||
candidates.extend(
|
||||
self.mobs
|
||||
@@ -517,8 +523,12 @@ impl Game<'_> {
|
||||
.projectiles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| p.arrow.is_some())
|
||||
.map(|(i, _)| Target::Arrow(i)),
|
||||
// The kinds /data can write (entity_nbt).
|
||||
.filter(|(_, p)| {
|
||||
p.arrow.is_some()
|
||||
|| matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
|
||||
})
|
||||
.map(|(i, _)| Target::Projectile(i)),
|
||||
);
|
||||
candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id)));
|
||||
candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id)));
|
||||
@@ -1100,9 +1110,15 @@ impl Game<'_> {
|
||||
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
|
||||
m.position = v;
|
||||
m.previous = v;
|
||||
if let Some([yaw, _]) = rotation {
|
||||
// Entity.teleportSetPosition: setYRot, setYHeadRot, setXRot
|
||||
// (clamped to -90..90), then setOldRot.
|
||||
if let Some([yaw, pitch]) = rotation {
|
||||
m.yaw = yaw;
|
||||
m.body_yaw = yaw;
|
||||
m.head_yaw = yaw;
|
||||
m.head_pitch = pitch.clamp(-90.0, 90.0);
|
||||
m.previous_yaw = m.yaw;
|
||||
m.previous_head_pitch = m.head_pitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1118,7 +1134,7 @@ impl Game<'_> {
|
||||
o.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Arrow(i) => {
|
||||
Target::Projectile(i) => {
|
||||
if let Some(a) = self.creatures.projectiles.get_mut(i) {
|
||||
a.position = v;
|
||||
a.previous = v;
|
||||
@@ -1542,14 +1558,10 @@ impl Game<'_> {
|
||||
self.orbs[i].age = u32::MAX / 2;
|
||||
}
|
||||
}
|
||||
Target::Arrow(i) => {
|
||||
if let Some(a) = self
|
||||
.creatures
|
||||
.projectiles
|
||||
.get_mut(i)
|
||||
.and_then(|p| p.arrow.as_mut())
|
||||
{
|
||||
a.removed = true;
|
||||
// cmd_kill goes from the highest index down.
|
||||
Target::Projectile(i) => {
|
||||
if i < self.creatures.projectiles.len() {
|
||||
self.creatures.projectiles.remove(i);
|
||||
}
|
||||
}
|
||||
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
|
||||
@@ -1571,7 +1583,7 @@ impl Game<'_> {
|
||||
let mut ordered = targets.clone();
|
||||
ordered.sort_by_key(|t| {
|
||||
std::cmp::Reverse(match t {
|
||||
Target::Orb(i) | Target::Arrow(i) => *i,
|
||||
Target::Orb(i) | Target::Projectile(i) => *i,
|
||||
_ => 0,
|
||||
})
|
||||
});
|
||||
@@ -1579,9 +1591,6 @@ impl Game<'_> {
|
||||
self.kill_target(t);
|
||||
}
|
||||
self.orbs.retain(|o| o.age < u32::MAX / 2);
|
||||
self.creatures
|
||||
.projectiles
|
||||
.retain(|p| !p.arrow.as_ref().is_some_and(|a| a.removed));
|
||||
let message = if targets.len() == 1 {
|
||||
Text::translate("commands.kill.success.single", &[names[0].clone()])
|
||||
} else {
|
||||
|
||||
@@ -114,7 +114,7 @@ impl Game<'_> {
|
||||
self.other_to_nbt(&o, uuid)
|
||||
}
|
||||
Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()),
|
||||
Target::Arrow(i) => {
|
||||
Target::Projectile(i) => {
|
||||
let p = self.creatures.projectiles.get(i)?;
|
||||
let owner = if p.owner == Some(horizon_client::mobs::Who::Player) {
|
||||
player_uuid
|
||||
@@ -126,7 +126,8 @@ impl Game<'_> {
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)?
|
||||
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))?
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -513,7 +513,10 @@ impl Game<'_> {
|
||||
if self.pending_screenshot.is_some() {
|
||||
return Err("a screenshot is already pending".into());
|
||||
}
|
||||
renderer.screenshot = Some(arg(1)?.into());
|
||||
arg(1)?;
|
||||
// The rest of the line: a path may hold spaces.
|
||||
let path = line.trim_start().strip_prefix("screenshot").unwrap_or("");
|
||||
renderer.screenshot = Some(path.trim().into());
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! What the player is doing, for Discord Rich Presence (`horizon_client::discord_presence`).
|
||||
use super::*;
|
||||
use horizon_client::discord_presence::{self as presence, Dimension, Play, Status};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the presence thread.
|
||||
pub(super) fn update_discord_presence(&self) {
|
||||
presence::set(self.discord_status());
|
||||
}
|
||||
fn discord_status(&self) -> Status {
|
||||
if !self.settings.extras.discord_presence {
|
||||
return Status::Off;
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return Status::Menus;
|
||||
}
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Status::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: self.mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
|
||||
fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
|
||||
match fog_type(&self.world, self.res.facts(), camera, self.forward()) {
|
||||
FogType::Water => CameraFluid::Water,
|
||||
FogType::Lava => CameraFluid::Lava,
|
||||
|
||||
@@ -321,20 +321,7 @@ impl Game<'_> {
|
||||
}
|
||||
for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) {
|
||||
use horizon_client::projectiles::ProjectileKind as K;
|
||||
let kind = match &p.kind {
|
||||
K::Arrow { .. } => "minecraft:arrow",
|
||||
K::Snowball => "minecraft:snowball",
|
||||
K::Egg => "minecraft:egg",
|
||||
K::EnderPearl => "minecraft:ender_pearl",
|
||||
K::SmallFireball => "minecraft:small_fireball",
|
||||
K::LargeFireball { .. } => "minecraft:fireball",
|
||||
K::LlamaSpit => "minecraft:llama_spit",
|
||||
K::WindCharge => "minecraft:wind_charge",
|
||||
K::BreezeWindCharge => "minecraft:breeze_wind_charge",
|
||||
K::ShulkerBullet { .. } => "minecraft:shulker_bullet",
|
||||
K::WitherSkull { .. } => "minecraft:wither_skull",
|
||||
K::Potion { .. } => "minecraft:splash_potion",
|
||||
};
|
||||
let kind = p.kind.id();
|
||||
let mut e = state(p.id, kind, at(p.position), p.yaw, false);
|
||||
e.pitch = p.pitch;
|
||||
e.velocity = at(p.velocity);
|
||||
|
||||
@@ -156,6 +156,8 @@ impl Game<'_> {
|
||||
kind.as_str(),
|
||||
"minecraft:experience_orb"
|
||||
| "minecraft:arrow"
|
||||
| "minecraft:fireball"
|
||||
| "minecraft:small_fireball"
|
||||
| "minecraft:falling_block"
|
||||
| "minecraft:end_crystal"
|
||||
| "minecraft:ender_dragon"
|
||||
@@ -195,6 +197,11 @@ impl Game<'_> {
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
}
|
||||
"minecraft:fireball" | "minecraft:small_fireball" => {
|
||||
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
|
||||
self.creatures.projectiles.push(p);
|
||||
}
|
||||
}
|
||||
"minecraft:falling_block" => {
|
||||
if let Some((state, position, velocity, age, fall_distance)) =
|
||||
entity_nbt::falling_block_from_nbt(&tag)
|
||||
@@ -642,13 +649,15 @@ impl Game<'_> {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:arrow", p.position);
|
||||
let (uuid, raw) = anon(&self.persist, p.kind.id(), p.position);
|
||||
let in_block = p
|
||||
.arrow
|
||||
.as_ref()
|
||||
.and_then(|a| a.last_state)
|
||||
.map(|s| self.state_to_nbt(s));
|
||||
if let Some(tag) = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) {
|
||||
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
|
||||
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()));
|
||||
if let Some(tag) = tag {
|
||||
out.entry(chunk_of(p.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +193,9 @@ pub(super) struct Remote {
|
||||
/// the oldest of them first waited (`poll_remote`).
|
||||
backlog: VecDeque<Event>,
|
||||
backlog_since: Option<Instant>,
|
||||
/// `ClientboundTransferPacket`'s target: when the connection ends the
|
||||
/// game connects here, the stored cookies carried over (`TransferState`).
|
||||
transfer: Option<String>,
|
||||
}
|
||||
|
||||
/// The time a frame spends on chunk packets before it leaves the rest of
|
||||
@@ -257,14 +260,25 @@ fn number_format(f: &play::NumberFormat) -> horizon_client::scoreboard::NumberFo
|
||||
impl Game<'_> {
|
||||
/// Leave the title for a server (ConnectScreen): an empty world fed by the connection.
|
||||
pub(super) fn connect_server(&mut self, address: &str, name: &str) -> Result<()> {
|
||||
self.connect_server_with_cookies(address, name, None)
|
||||
}
|
||||
/// `connect_server` carrying a transfer's stored server cookies
|
||||
/// (`TransferState`); None on an ordinary join.
|
||||
pub(super) fn connect_server_with_cookies(
|
||||
&mut self,
|
||||
address: &str,
|
||||
name: &str,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
if self.defer_world_entry(super::PendingWorldEntry::Server {
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
cookies: cookies.clone(),
|
||||
})? {
|
||||
return Ok(());
|
||||
}
|
||||
self.join_world_rest()?;
|
||||
if let Err(error) = self.join_server(address, name, None) {
|
||||
if let Err(error) = self.join_server(address, name, None, cookies) {
|
||||
log::warn!("Horizon: could not join server: {error}");
|
||||
if self.title.is_none() {
|
||||
self.title = Some(horizon_client::title::Title::new(
|
||||
@@ -285,13 +299,14 @@ impl Game<'_> {
|
||||
pipe: horizon_protocol::connection::Pipe,
|
||||
name: &str,
|
||||
) -> Result<()> {
|
||||
self.join_server("local", name, Some(pipe))
|
||||
self.join_server("local", name, Some(pipe), None)
|
||||
}
|
||||
fn join_server(
|
||||
&mut self,
|
||||
address: &str,
|
||||
name: &str,
|
||||
local: Option<horizon_protocol::connection::Pipe>,
|
||||
cookies: Option<horizon_client::net::Cookies>,
|
||||
) -> Result<()> {
|
||||
self.perf.world_opening();
|
||||
let root = self.options.resources.clone();
|
||||
@@ -323,7 +338,17 @@ impl Game<'_> {
|
||||
Some(pipe) => Session::connect_local(pipe, name, view, lang.clone()),
|
||||
None => {
|
||||
self.extras_connecting(address, name);
|
||||
Session::connect(address, name, auth.clone(), view, lang.clone())
|
||||
match cookies {
|
||||
Some(cookies) => Session::connect_transferred(
|
||||
address,
|
||||
name,
|
||||
auth.clone(),
|
||||
view,
|
||||
lang.clone(),
|
||||
cookies,
|
||||
),
|
||||
None => Session::connect(address, name, auth.clone(), view, lang.clone()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let player_type = super::perspective::player_type(&root);
|
||||
@@ -446,6 +471,7 @@ impl Game<'_> {
|
||||
chunk_center: (0, 0),
|
||||
backlog: VecDeque::new(),
|
||||
backlog_since: None,
|
||||
transfer: None,
|
||||
});
|
||||
// The level's TickRateManager follows the server's ticking packets.
|
||||
self.clock = horizon_client::clock::FixedStep::client();
|
||||
@@ -867,6 +893,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
Event::Reconfigured(registries) => self.set_registries(registries),
|
||||
// handleTransfer: "Cannot transfer to server from
|
||||
// singleplayer" (no ServerData), so the world just closes.
|
||||
Event::Transfer { host, port } => {
|
||||
if let Some(r) = self.remote.as_mut()
|
||||
&& r.session.address != "local"
|
||||
{
|
||||
r.transfer = Some(format!("{host}:{port}"));
|
||||
}
|
||||
}
|
||||
Event::PackPush(p) => self.handle_pack_push(p),
|
||||
Event::PackPop(id) => self.handle_pack_pop(id),
|
||||
Event::DecodeError(e) => {
|
||||
@@ -882,6 +917,20 @@ impl Game<'_> {
|
||||
from_server,
|
||||
} => {
|
||||
log::info!("Horizon: disconnected: {reason}");
|
||||
// ClientboundTransferPacket left a target: connect there
|
||||
// instead of showing the disconnect, the stored cookies
|
||||
// carried over (ClientCommonPacketListenerImpl.
|
||||
// handleTransfer → ConnectScreen.startConnecting).
|
||||
let transfer = self.remote.as_mut().and_then(|r| {
|
||||
r.transfer.take().map(|address| {
|
||||
(address, r.session.name.clone(), r.session.cookies.clone())
|
||||
})
|
||||
});
|
||||
if let Some((address, name, cookies)) = transfer {
|
||||
self.leave_server(None)?;
|
||||
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
|
||||
return Ok(());
|
||||
}
|
||||
let retry = self.reconnect_after(&reason, from_server);
|
||||
self.leave_server(Some(reason))?;
|
||||
self.offer_reconnect(retry);
|
||||
@@ -1015,6 +1064,9 @@ impl Game<'_> {
|
||||
r.stepped.clear();
|
||||
r.head_lerps.clear();
|
||||
r.pending_items.clear();
|
||||
// The entity ids are the old world's; the new dimension's
|
||||
// player entities re-map to their profiles on AddEntity.
|
||||
r.level_players.clear();
|
||||
// A new ClientLevel: a new chunk storage, centred on 0, 0.
|
||||
r.chunk_center = (0, 0);
|
||||
}
|
||||
@@ -3927,7 +3979,7 @@ impl Game<'_> {
|
||||
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
|
||||
// A boat, minecart, item frame, armor stand or painting nearer than
|
||||
// any mob: the server uses it (AbstractBoat.interact boards it).
|
||||
if let Some(hit) = self.remote_object_hit()
|
||||
if let Some(hit) = self.remote_object_hit(false)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
@@ -4436,12 +4488,12 @@ impl Game<'_> {
|
||||
id: self.creatures.projectiles[i].id as i32,
|
||||
}));
|
||||
self.reset_attack_strength();
|
||||
} else if let Some(hit) = self.remote_object_hit()
|
||||
} else if let Some(hit) = self.remote_object_hit(true)
|
||||
&& self.mob_hit().is_none_or(|(_, d)| hit.distance < d)
|
||||
&& self.before_block(hit.distance)
|
||||
{
|
||||
// VehicleEntity.hurtServer, ItemFrame and ArmorStand hurtServer,
|
||||
// a painting broken: the server decides.
|
||||
// a painting broken or an EndCrystal hurtServer: the server decides.
|
||||
self.send(S::Attack(play::Attack { id: hit.id }));
|
||||
self.reset_attack_strength();
|
||||
} else if let Some((i, distance)) = self.mob_hit()
|
||||
@@ -4796,6 +4848,11 @@ impl Game<'_> {
|
||||
self.mount_menu_reset();
|
||||
// The client's menu has no level access: what it holds is the server's.
|
||||
self.station = None;
|
||||
// AbstractContainerMenu.removed: the server gives the grid and the
|
||||
// carried stack back to the inventory and syncs it; the copies here
|
||||
// are stale the moment the window closes.
|
||||
self.craft_grid.fill(None);
|
||||
self.carried = None;
|
||||
self.craft_size = 2;
|
||||
if let Some(key) = self.container.take() {
|
||||
// Server containers stay renderable but lose their copied slots.
|
||||
@@ -5450,6 +5507,10 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Players in the server's player list, this one included.
|
||||
pub(super) fn player_count(&self) -> usize {
|
||||
self.players.len()
|
||||
}
|
||||
/// The local player's profile UUID.
|
||||
pub(super) fn profile(&self) -> u128 {
|
||||
self.profile
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
|
||||
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
|
||||
//! the server with the rider's input, and the rider sits in it.
|
||||
use super::{Game, Keys, view_vector};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use super::{Game, Keys, ray_box, view_vector};
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_protocol::packets::play::{self, Serverbound as S};
|
||||
|
||||
/// An object under the crosshair: its entity id, how far along the view it
|
||||
@@ -24,8 +24,8 @@ pub(super) struct ObjectHit {
|
||||
|
||||
impl Game<'_> {
|
||||
/// The nearest boat, minecart, item frame, armor stand or painting the
|
||||
/// view hits within reach.
|
||||
pub(super) fn remote_object_hit(&self) -> Option<ObjectHit> {
|
||||
/// view hits within reach; also End crystals when attacking.
|
||||
pub(super) fn remote_object_hit(&self, attacking: bool) -> Option<ObjectHit> {
|
||||
let eye = self.eye_position()?;
|
||||
let dir = view_vector(self.yaw, self.pitch);
|
||||
let at = |d: f64| {
|
||||
@@ -64,6 +64,22 @@ impl Game<'_> {
|
||||
let p = self.carts[i].position;
|
||||
hits.push((self.carts[i].id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
if attacking {
|
||||
// EndCrystal.isPickable: its 2 x 2 box, with the server's entity
|
||||
// id, not special_targets' local projectile target id.
|
||||
// Entity.interact passes on a crystal, so right clicks use the item.
|
||||
let reach = if self.creative { 5.0 } else { 3.0 };
|
||||
for c in self.crystals.iter().filter(|c| c.id != 0) {
|
||||
let p = c.position;
|
||||
let bounds = Aabb::new(
|
||||
Vec3::new(p.x - 1.0, p.y, p.z - 1.0),
|
||||
Vec3::new(p.x + 1.0, p.y + 2.0, p.z + 1.0),
|
||||
);
|
||||
if let Some(d) = ray_box(eye, dir, reach, bounds) {
|
||||
hits.push((c.id as i32, d, [p.x, p.y, p.z]));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (id, distance, origin) = hits.into_iter().min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let point = at(distance);
|
||||
Some(ObjectHit {
|
||||
|
||||
@@ -180,7 +180,7 @@ impl Game<'_> {
|
||||
| Target::Cart(id)
|
||||
| Target::Other(id) => self.persist.raw.get(&id),
|
||||
Target::Player => self.level.player_extra.as_ref(),
|
||||
Target::Guest(_) | Target::Orb(_) | Target::Arrow(_) => None,
|
||||
Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None,
|
||||
};
|
||||
match raw.and_then(|r| r.get("Tags")) {
|
||||
Some(Tag::List(l)) => l
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
|
||||
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world. The
|
||||
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
|
||||
|
||||
use horizon_client::camera::CameraFluid;
|
||||
use horizon_client::gfx::pack_uniforms::PackInputs;
|
||||
use horizon_shaders::{Macros, Pack, Properties, dimension_folder, load_post};
|
||||
|
||||
use super::{Dim, Game};
|
||||
|
||||
impl Game<'_> {
|
||||
/// Loads the pack for the current dimension when it changed, and returns this frame's
|
||||
/// inputs to the pack's uniforms.
|
||||
pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs {
|
||||
let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else {
|
||||
return PackInputs::default();
|
||||
};
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
Dim::Nether => -1,
|
||||
Dim::End => 1,
|
||||
};
|
||||
if let Some(renderer) = &mut self.renderer
|
||||
&& renderer.shader_pack_dimension() != Some(dimension)
|
||||
{
|
||||
let name = name.to_string_lossy();
|
||||
let started = std::time::Instant::now();
|
||||
let path = std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join("shaderpacks")
|
||||
.join(&*name);
|
||||
match Pack::open(&path) {
|
||||
Ok(pack) => {
|
||||
let macros = Macros::standard();
|
||||
let text = pack.read_text("shaders.properties").unwrap_or_default();
|
||||
let properties = Properties::parse(&text, ¯os);
|
||||
for note in &properties.notes {
|
||||
log::warn!(
|
||||
"shader pack {name}: shaders.properties:{}: {}",
|
||||
note.line,
|
||||
note.message
|
||||
);
|
||||
}
|
||||
let folder = dimension_folder(&pack, dimension);
|
||||
let post = load_post(&pack, &folder, &properties, ¯os, None);
|
||||
if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post))
|
||||
{
|
||||
for note in notes {
|
||||
log::warn!("shader pack {name}: {note}");
|
||||
}
|
||||
log::info!(
|
||||
"shader pack {name}: folder `{folder}`, {} programs ({}) in {} ms",
|
||||
programs.len(),
|
||||
programs.join(" "),
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("shader pack {name}: {e}");
|
||||
renderer.set_shader_pack(dimension, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
let lerp = |(old, new): (f32, f32)| old + (new - old) * partial;
|
||||
let at = camera.floor().as_ivec3();
|
||||
let light = self.world.light(at.x, at.y, at.z);
|
||||
PackInputs {
|
||||
day_time: self.world.day_time,
|
||||
rain: lerp(self.world.weather.rain_level),
|
||||
thunder: lerp(self.world.weather.thunder_level),
|
||||
eye_in: match self.camera_fluid(camera.to_array()) {
|
||||
CameraFluid::None => 0,
|
||||
CameraFluid::Water => 1,
|
||||
CameraFluid::Lava => 2,
|
||||
CameraFluid::PowderSnow => 3,
|
||||
},
|
||||
blindness: f32::from(u8::from(self.potions.effects.get("blindness").is_some())),
|
||||
eye_light: [i32::from(light & 15), i32::from(light >> 4)],
|
||||
hide_gui: self.ui.hide_gui,
|
||||
brightness: self.settings.gamma,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ impl StartupMenu {
|
||||
Some(PendingWorldEntry::Server {
|
||||
address: address.clone(),
|
||||
name: options.player_name(),
|
||||
cookies: None,
|
||||
})
|
||||
} else {
|
||||
options
|
||||
@@ -125,6 +126,7 @@ impl StartupMenu {
|
||||
Action::Connect { address } => PendingWorldEntry::Server {
|
||||
address,
|
||||
name: player_name,
|
||||
cookies: None,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
@@ -762,7 +764,7 @@ mod tests {
|
||||
"Guest".into(),
|
||||
);
|
||||
assert!(
|
||||
matches!(menu.pending.take(), Some(PendingWorldEntry::Server { address, name })
|
||||
matches!(menu.pending.take(), Some(PendingWorldEntry::Server { address, name, .. })
|
||||
if address == "localhost:25565" && name == "Guest")
|
||||
);
|
||||
assert!(menu.pending.take().is_none(), "an entry is consumed once");
|
||||
|
||||
@@ -68,6 +68,9 @@ mod flat;
|
||||
pub mod lightning;
|
||||
mod lod;
|
||||
mod occlusion;
|
||||
mod pack_atlas;
|
||||
mod pack_post;
|
||||
pub mod pack_uniforms;
|
||||
mod page_runs;
|
||||
pub mod panorama;
|
||||
mod resident;
|
||||
@@ -87,6 +90,7 @@ fn world_pass_after_culling<'e>(
|
||||
encoder: &'e mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
depth: &wgpu::TextureView,
|
||||
keep_depth: bool,
|
||||
) -> wgpu::RenderPass<'e> {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Horizon world, after culling"),
|
||||
@@ -103,7 +107,12 @@ fn world_pass_after_culling<'e>(
|
||||
view: depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Discard,
|
||||
// A shader pack reads it as depthtex0.
|
||||
store: if keep_depth {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
},
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
@@ -249,6 +258,8 @@ pub struct Frame<'a> {
|
||||
pub hand_fov_degrees: f32,
|
||||
/// Full-screen camera effects drawn over the world and hand, under the HUD.
|
||||
pub screen: &'a [crate::screen_effects::ScreenQuad],
|
||||
/// What a shader pack's uniforms need from the game.
|
||||
pub pack: pack_uniforms::PackInputs,
|
||||
/// The world border wall, when near enough to be drawn.
|
||||
pub border: Option<BorderDraw>,
|
||||
pub game_time: f32,
|
||||
@@ -460,6 +471,10 @@ pub struct Renderer {
|
||||
occlusion: occlusion::Occlusion,
|
||||
/// Resolution scale and FXAA (Horizon settings).
|
||||
world_post: world_post::Post,
|
||||
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
|
||||
pack_post: Option<(i32, Option<pack_post::PackPost>)>,
|
||||
/// The block sprites as one atlas, while a shader pack is loaded.
|
||||
pack_atlas: Option<pack_atlas::PackAtlas>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -1308,6 +1323,8 @@ impl Renderer {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
// Shader packs' atlas copies them (pack_atlas.rs).
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::STORAGE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
@@ -2305,6 +2322,8 @@ impl Renderer {
|
||||
block_entity_revision: next_block_entity_revision(),
|
||||
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
|
||||
world_post,
|
||||
pack_post: None,
|
||||
pack_atlas: None,
|
||||
hud,
|
||||
menu_blur: None,
|
||||
info,
|
||||
@@ -2735,6 +2754,51 @@ impl Renderer {
|
||||
self.world_post.scale = scale.clamp(0.25, 2.0);
|
||||
self.world_post.antialias = antialias;
|
||||
}
|
||||
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`.
|
||||
/// Returns the programs that run and the notes of loading them.
|
||||
pub fn set_shader_pack(
|
||||
&mut self,
|
||||
dimension: i32,
|
||||
pack: Option<horizon_shaders::Post>,
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
let post = pack.map(|post| {
|
||||
pack_post::PackPost::new(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
post,
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
)
|
||||
});
|
||||
if post.is_none() {
|
||||
self.pack_atlas = None;
|
||||
} else if self.pack_atlas.is_none() {
|
||||
let mut encoder = self.device.create_command_encoder(&Default::default());
|
||||
let layers = self
|
||||
.sprite_textures
|
||||
.iter()
|
||||
.map(|t| t.depth_or_array_layers())
|
||||
.sum();
|
||||
self.pack_atlas = pack_atlas::PackAtlas::new(
|
||||
&self.device,
|
||||
&mut encoder,
|
||||
&self.sprite_textures,
|
||||
sprite_split(),
|
||||
layers,
|
||||
self.sprite_textures[0].width(),
|
||||
);
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
let report = post.as_ref().map(|p| {
|
||||
let programs = p.programs().into_iter().map(str::to_owned).collect();
|
||||
(programs, p.notes.clone())
|
||||
});
|
||||
self.pack_post = Some((dimension, post));
|
||||
report
|
||||
}
|
||||
/// The dimension the shader pack was last set for.
|
||||
pub fn shader_pack_dimension(&self) -> Option<i32> {
|
||||
self.pack_post.as_ref().map(|p| p.0)
|
||||
}
|
||||
/// The Mipmap Levels option: block textures sample at most `levels`
|
||||
/// reduced copies (0: none).
|
||||
pub fn set_mipmap_levels(&mut self, levels: u32) {
|
||||
@@ -2854,6 +2918,13 @@ impl Renderer {
|
||||
}
|
||||
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) {
|
||||
self.animations.animate(encoder, &mut self.uploads, tick);
|
||||
if let Some(atlas) = &self.pack_atlas {
|
||||
atlas.copy(
|
||||
encoder,
|
||||
&self.sprite_textures,
|
||||
self.animations.changed().iter().copied(),
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
|
||||
let rendered = self.draw_frame(frame);
|
||||
@@ -3600,9 +3671,12 @@ impl Renderer {
|
||||
timer.begin(&mut encoder);
|
||||
}
|
||||
// The world's targets: the screen, or the scaled target of `post`.
|
||||
let scaled = self
|
||||
.world_post
|
||||
.prepare(&self.device, [self.config.width, self.config.height]);
|
||||
let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some());
|
||||
let scaled = self.world_post.prepare(
|
||||
&self.device,
|
||||
[self.config.width, self.config.height],
|
||||
pack_post,
|
||||
);
|
||||
let (world_view, world_depth) = match &scaled {
|
||||
Some((color, depth)) => (color, depth),
|
||||
None => (&view_target, &self.depth),
|
||||
@@ -3639,8 +3713,8 @@ impl Renderer {
|
||||
view: world_depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
// Phase 2 of occlusion culling draws over it.
|
||||
store: if cull.is_some() {
|
||||
// Phase 2 of occlusion culling draws over it; a shader pack reads it.
|
||||
store: if cull.is_some() || pack_post {
|
||||
wgpu::StoreOp::Store
|
||||
} else {
|
||||
wgpu::StoreOp::Discard
|
||||
@@ -3699,7 +3773,12 @@ impl Renderer {
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder);
|
||||
flat.flatten_phase2(&mut encoder);
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth);
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_post,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&flat.solid);
|
||||
flat.draw_phase2(&mut pass, vertices);
|
||||
@@ -3723,7 +3802,12 @@ impl Renderer {
|
||||
cull.test(&mut pass);
|
||||
drop(pass);
|
||||
cull.test_pass(&mut encoder);
|
||||
pass = world_pass_after_culling(&mut encoder, world_view, world_depth);
|
||||
pass = world_pass_after_culling(
|
||||
&mut encoder,
|
||||
world_view,
|
||||
world_depth,
|
||||
pack_post,
|
||||
);
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
pass.set_pipeline(&self.solid);
|
||||
pass.set_index_buffer(
|
||||
@@ -3882,7 +3966,24 @@ impl Renderer {
|
||||
);
|
||||
}
|
||||
}
|
||||
if scaled.is_some() {
|
||||
if let Some((color, depth)) = &scaled
|
||||
&& pack_post
|
||||
&& let Some((_, Some(post))) = &mut self.pack_post
|
||||
{
|
||||
let size = self.world_post.size().unwrap_or([1, 1]);
|
||||
post.run(
|
||||
&self.device,
|
||||
&self.queue,
|
||||
&mut encoder,
|
||||
color,
|
||||
depth,
|
||||
&view_target,
|
||||
frame,
|
||||
&frame.pack,
|
||||
size,
|
||||
[self.config.width, self.config.height],
|
||||
);
|
||||
} else if scaled.is_some() {
|
||||
self.world_post.finish(&mut encoder, &view_target);
|
||||
}
|
||||
if hand_quads > 0 {
|
||||
|
||||
@@ -51,6 +51,8 @@ pub(super) struct SpriteAnimator {
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
/// Edge of the arrays' layers.
|
||||
size: u32,
|
||||
/// The layers the last `animate` rewrote, counted over all arrays.
|
||||
changed: Vec<u32>,
|
||||
}
|
||||
|
||||
impl SpriteAnimator {
|
||||
@@ -205,9 +207,15 @@ impl SpriteAnimator {
|
||||
jobs,
|
||||
pipeline,
|
||||
size,
|
||||
changed: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers the last `animate` rewrote (for the shader pack atlas).
|
||||
pub fn changed(&self) -> &[u32] {
|
||||
&self.changed
|
||||
}
|
||||
|
||||
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
|
||||
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
|
||||
pub fn animate(
|
||||
@@ -245,12 +253,14 @@ impl SpriteAnimator {
|
||||
/// its layer shows; the number of jobs.
|
||||
fn collect(&mut self, tick: u64) -> usize {
|
||||
let mut count = 0;
|
||||
self.changed.clear();
|
||||
for sprite in &mut self.sprites {
|
||||
let blend = sprite.animation.blend(tick);
|
||||
if sprite.shown == Some(blend) {
|
||||
continue;
|
||||
}
|
||||
sprite.shown = Some(blend);
|
||||
self.changed.push(sprite.animation.layer);
|
||||
let job = [
|
||||
sprite.layer,
|
||||
sprite.frames[blend.frame],
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas
|
||||
//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every
|
||||
//! layer is copied into a grid, row by row from the top left, and an animated layer again
|
||||
//! whenever its frame changes.
|
||||
|
||||
#[expect(dead_code, reason = "the gbuffers pass binds it (next slice)")]
|
||||
pub(super) struct PackAtlas {
|
||||
texture: wgpu::Texture,
|
||||
pub view: wgpu::TextureView,
|
||||
/// Sprites per row.
|
||||
pub columns: u32,
|
||||
/// A sprite's edge in pixels, at the first level.
|
||||
pub sprite: u32,
|
||||
/// Layers per sprite array.
|
||||
split: u32,
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
/// Copies `layers` layers of `arrays` (`split` per array, `sprite` pixels wide, `levels`
|
||||
/// mip levels); `None` when the grid is larger than the device allows.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
layers: u32,
|
||||
sprite: u32,
|
||||
) -> Option<Self> {
|
||||
let layers = layers.max(1);
|
||||
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
|
||||
let rows = layers.div_ceil(columns);
|
||||
let max = device.limits().max_texture_dimension_2d;
|
||||
if columns * sprite > max || rows * sprite > max {
|
||||
return None;
|
||||
}
|
||||
// Levels past a sprite's single pixel would mix neighbours: stop there.
|
||||
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
width: columns * sprite,
|
||||
height: rows * sprite,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_DST
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let atlas = Self {
|
||||
texture,
|
||||
view,
|
||||
columns,
|
||||
sprite,
|
||||
split,
|
||||
};
|
||||
atlas.copy(encoder, arrays, 0..layers);
|
||||
Some(atlas)
|
||||
}
|
||||
|
||||
/// The atlas's size in pixels (`atlasSize`).
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(dead_code, reason = "the gbuffers pass reads it (next slice)")
|
||||
)]
|
||||
pub fn size(&self) -> [u32; 2] {
|
||||
[self.texture.width(), self.texture.height()]
|
||||
}
|
||||
|
||||
/// Copies `layers` from the sprite arrays again, every level.
|
||||
pub fn copy(
|
||||
&self,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
layers: impl IntoIterator<Item = u32>,
|
||||
) {
|
||||
for layer in layers {
|
||||
let Some(array) = arrays.get((layer / self.split) as usize) else {
|
||||
continue;
|
||||
};
|
||||
let (column, row) = (layer % self.columns, layer / self.columns);
|
||||
for level in 0..self.texture.mip_level_count() {
|
||||
let edge = self.sprite >> level;
|
||||
encoder.copy_texture_to_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: array,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: layer % self.split,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &self.texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: column * edge,
|
||||
y: row * edge,
|
||||
z: 0,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: edge,
|
||||
height: edge,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own.
|
||||
#[test]
|
||||
fn layers_land_in_their_cells() {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let (split, layers, sprite) = (3u32, 5u32, 2u32);
|
||||
let arrays: Vec<wgpu::Texture> = (0..2)
|
||||
.map(|a| {
|
||||
let count = (layers - a * split).min(split);
|
||||
let pixels: Vec<u8> = (0..count)
|
||||
.flat_map(|l| [(a * split + l) as u8 * 40, 0, 0, 255].repeat(4))
|
||||
.collect();
|
||||
wgpu::util::DeviceExt::create_texture_with_data(
|
||||
&device,
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: sprite,
|
||||
height: sprite,
|
||||
depth_or_array_layers: count,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&pixels,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlas = PackAtlas::new(&device, &mut encoder, &arrays, split, layers, sprite).unwrap();
|
||||
assert_eq!((atlas.columns, atlas.size()), (3, [6, 4]));
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256 * 4,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
atlas.texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 6,
|
||||
height: 4,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
let red = |x: usize, y: usize| data[y * 256 + x * 4];
|
||||
// Layer 4 (second array, its second layer) is in row 1, column 1.
|
||||
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
|
||||
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,929 @@
|
||||
//! A shader pack's post-processing over Horizon's own world (spec 2.1 rows 4, 6, 8, 9; 6.1; 6.2;
|
||||
//! 6.4): the world renders into its own target as with a resolution scale, its colour becomes
|
||||
//! colortex0 and its depth `depthtex0`, then the pack's `prepare`, `deferred`, `composite` and
|
||||
//! `final` programs run in order, and `final` draws onto the screen under the hand and HUD.
|
||||
//! Gbuffers programs come later; until then the pack's composites read Horizon's picture.
|
||||
//!
|
||||
//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read
|
||||
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The
|
||||
//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was
|
||||
//! written last (OQ-8). Buffers are cleared at the start of the frame, "main" only.
|
||||
//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so
|
||||
//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use horizon_shaders::{Format, Group, Kind, Post};
|
||||
|
||||
use super::pack_uniforms::{self, FrameUniforms, PackInputs};
|
||||
|
||||
const BUFFERS: usize = horizon_shaders::BUFFERS;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
Color(usize),
|
||||
Depth,
|
||||
Noise,
|
||||
/// A 1×1 stand-in for names this slice does not provide yet (custom textures...).
|
||||
Dummy,
|
||||
/// A 1×1 depth of 1.0 behind a comparison sampler, until shadow maps exist: no shadow.
|
||||
Shadow,
|
||||
}
|
||||
|
||||
struct Binding {
|
||||
source: Source,
|
||||
binding: u32,
|
||||
/// A filtering sampler (the texture is filterable).
|
||||
filtering: bool,
|
||||
}
|
||||
|
||||
struct Pass {
|
||||
name: String,
|
||||
group: Group,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
ubo: wgpu::Buffer,
|
||||
block: Vec<u8>,
|
||||
/// (index into `pack_uniforms::NAMES`, declared type, offset).
|
||||
uniforms: Vec<(Option<usize>, String, u32)>,
|
||||
samplers: Vec<Binding>,
|
||||
/// (location, buffer) of each output that reaches a buffer.
|
||||
writes: Vec<(u32, usize)>,
|
||||
/// Buffers flipped after the pass.
|
||||
flip: u16,
|
||||
/// Buffers sampled, for the bind group cache key.
|
||||
reads: u16,
|
||||
/// Buffers written.
|
||||
written: u16,
|
||||
groups: HashMap<u16, wgpu::BindGroup>,
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
/// A and B of each buffer used.
|
||||
color: [Option<[wgpu::TextureView; 2]>; BUFFERS],
|
||||
depth: wgpu::TextureView,
|
||||
}
|
||||
|
||||
pub struct PackPost {
|
||||
pub notes: Vec<String>,
|
||||
passes: Vec<Pass>,
|
||||
deferred_pre: u16,
|
||||
composite_pre: u16,
|
||||
formats: [wgpu::TextureFormat; BUFFERS],
|
||||
clears: [Option<[f32; 4]>; BUFFERS],
|
||||
used: u16,
|
||||
/// Bit n set: B is buffer n's "main".
|
||||
main: u16,
|
||||
targets: Option<Targets>,
|
||||
noise: wgpu::TextureView,
|
||||
dummy: wgpu::TextureView,
|
||||
/// The stand-in shadow map and its comparison sampler; cleared on the first frame.
|
||||
shadow: (wgpu::TextureView, wgpu::Sampler),
|
||||
shadow_cleared: bool,
|
||||
linear: wgpu::Sampler,
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
|
||||
/// The blit also writes depthtex0 (some program reads it) and clears these buffers.
|
||||
blit_depth: bool,
|
||||
blit_clears: Vec<usize>,
|
||||
/// Buffers cleared in passes of their own (more than the blit's eight attachments).
|
||||
other_clears: Vec<usize>,
|
||||
/// The blit's bindings, for the world target of this size.
|
||||
blit_group: Option<([u32; 2], wgpu::BindGroup)>,
|
||||
/// Shows colortex0 when the pack has no `final` (OQ-5).
|
||||
copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
|
||||
uniforms: FrameUniforms,
|
||||
}
|
||||
|
||||
/// The wgpu format of a pack format (6.7), the nearest one wgpu can render to.
|
||||
fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
|
||||
use wgpu::TextureFormat as T;
|
||||
let pick = |r, rg, rgba| match f.channels {
|
||||
1 => r,
|
||||
2 => rg,
|
||||
_ => rgba,
|
||||
};
|
||||
let half = pick(T::R16Float, T::Rg16Float, T::Rgba16Float);
|
||||
match (f.kind, f.bits) {
|
||||
(Kind::Unorm, 10) => T::Rgb10a2Unorm,
|
||||
(Kind::Unorm, 16) if features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) => {
|
||||
pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm)
|
||||
}
|
||||
(Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
|
||||
(Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
|
||||
(Kind::Float, 11) if features.contains(wgpu::Features::RG11B10UFLOAT_RENDERABLE) => {
|
||||
T::Rg11b10Ufloat
|
||||
}
|
||||
(Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint),
|
||||
(Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint),
|
||||
(Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint),
|
||||
(Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint),
|
||||
(Kind::Uint, 10) => T::Rgb10a2Uint,
|
||||
(Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
|
||||
(Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
|
||||
// Snorm, 16-bit norm without the feature, RGB9_E5: half floats hold them.
|
||||
_ => half,
|
||||
}
|
||||
}
|
||||
|
||||
fn source(name: &str) -> Source {
|
||||
match name {
|
||||
"depthtex0" | "gdepthtex" | "depthtex1" | "depthtex2" => Source::Depth,
|
||||
"noisetex" => Source::Noise,
|
||||
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
|
||||
}
|
||||
}
|
||||
|
||||
/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same.
|
||||
fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
let mut state = 0x2545_f491_4f6c_dd1du64;
|
||||
let texels: Vec<u8> = (0..256 * 256 * 4)
|
||||
.map(|i| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
if i % 4 == 3 { 255 } else { (state >> 56) as u8 }
|
||||
})
|
||||
.collect();
|
||||
texture(device, queue, "Shader pack noisetex", 256, &texels)
|
||||
}
|
||||
|
||||
fn texture(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
label: &str,
|
||||
side: u32,
|
||||
texels: &[u8],
|
||||
) -> wgpu::TextureView {
|
||||
let size = wgpu::Extent3d {
|
||||
width: side,
|
||||
height: side,
|
||||
depth_or_array_layers: 1,
|
||||
};
|
||||
let tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
tex.as_image_copy(),
|
||||
texels,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(side * 4),
|
||||
rows_per_image: None,
|
||||
},
|
||||
size,
|
||||
);
|
||||
tex.create_view(&Default::default())
|
||||
}
|
||||
|
||||
fn sampler(
|
||||
device: &wgpu::Device,
|
||||
filter: wgpu::FilterMode,
|
||||
address: wgpu::AddressMode,
|
||||
) -> wgpu::Sampler {
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack"),
|
||||
address_mode_u: address,
|
||||
address_mode_v: address,
|
||||
address_mode_w: address,
|
||||
mag_filter: filter,
|
||||
min_filter: filter,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `create` and returns its validation error, if any.
|
||||
fn checked<T>(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result<T, String> {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let value = create();
|
||||
match pollster::block_on(scope.pop()) {
|
||||
Some(e) => {
|
||||
// The details are in the error's sources.
|
||||
let mut message = e.to_string();
|
||||
let mut source = std::error::Error::source(&e);
|
||||
while let Some(s) = source {
|
||||
message = format!("{message}: {s}");
|
||||
source = s.source();
|
||||
}
|
||||
Err(message)
|
||||
}
|
||||
None => Ok(value),
|
||||
}
|
||||
}
|
||||
|
||||
/// A full-screen helper pipeline of ours (`pack_post.wgsl`).
|
||||
fn helper(
|
||||
device: &wgpu::Device,
|
||||
vertex: &str,
|
||||
entry: &str,
|
||||
entries: &[wgpu::BindGroupLayoutEntry],
|
||||
targets: &[Option<wgpu::ColorTargetState>],
|
||||
) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Shader pack helpers"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("pack_post.wgsl").into()),
|
||||
});
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(entry),
|
||||
entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(entry),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(entry),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &module,
|
||||
entry_point: Some(vertex),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &module,
|
||||
entry_point: Some(entry),
|
||||
compilation_options: Default::default(),
|
||||
targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
(pipeline, layout)
|
||||
}
|
||||
|
||||
fn entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry {
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty,
|
||||
count: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
|
||||
entry(
|
||||
binding,
|
||||
wgpu::BindingType::Texture {
|
||||
sample_type,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})
|
||||
}
|
||||
|
||||
fn attach(
|
||||
view: &wgpu::TextureView,
|
||||
ops: wgpu::Operations<wgpu::Color>,
|
||||
) -> Option<wgpu::RenderPassColorAttachment<'_>> {
|
||||
Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops,
|
||||
})
|
||||
}
|
||||
|
||||
impl PackPost {
|
||||
/// Builds the pipelines of `post` for a screen of `screen` format.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
post: Post,
|
||||
screen: wgpu::TextureFormat,
|
||||
) -> Self {
|
||||
let features = device.features();
|
||||
let formats: [wgpu::TextureFormat; BUFFERS] =
|
||||
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
|
||||
let clears = std::array::from_fn(|i| {
|
||||
let b = &post.config.buffers[i];
|
||||
b.clear.then(|| {
|
||||
b.clear_color
|
||||
.unwrap_or(if i == 1 { [1.0; 4] } else { [0.0; 4] })
|
||||
})
|
||||
});
|
||||
let mut notes = post.notes;
|
||||
notes.extend(post.config.notes);
|
||||
let mask =
|
||||
|flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
|
||||
let mut used = 1u16;
|
||||
let mut passes = Vec::new();
|
||||
for p in post.programs {
|
||||
let interface = &p.program.interface;
|
||||
let mut entries = vec![entry(
|
||||
0,
|
||||
wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
)];
|
||||
let mut samplers = Vec::new();
|
||||
let mut reads = 0u16;
|
||||
for s in &interface.samplers {
|
||||
let mut src = source(&s.name);
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut sample_type = match src {
|
||||
Source::Color(n) => formats[n]
|
||||
.sample_type(None, Some(features))
|
||||
.unwrap_or(float),
|
||||
Source::Depth => wgpu::TextureSampleType::Float { filterable: false },
|
||||
_ => float,
|
||||
};
|
||||
let int = !matches!(sample_type, wgpu::TextureSampleType::Float { .. });
|
||||
if s.ty == "sampler2DShadow" {
|
||||
(src, sample_type) = (Source::Shadow, wgpu::TextureSampleType::Depth);
|
||||
}
|
||||
let wanted = match s.ty.as_str() {
|
||||
"sampler2DShadow" => true,
|
||||
"sampler2D" => !int,
|
||||
"isampler2D" => sample_type == wgpu::TextureSampleType::Sint,
|
||||
"usampler2D" => sample_type == wgpu::TextureSampleType::Uint,
|
||||
_ => false,
|
||||
};
|
||||
if !wanted {
|
||||
notes.push(format!(
|
||||
"{}: `{} {}` is not provided yet",
|
||||
p.name, s.ty, s.name
|
||||
));
|
||||
(src, sample_type) = (Source::Dummy, float);
|
||||
}
|
||||
let filtering = sample_type == float;
|
||||
if let Source::Color(n) = src {
|
||||
reads |= 1 << n;
|
||||
}
|
||||
entries.push(texture_entry(s.binding, sample_type));
|
||||
entries.push(entry(
|
||||
s.binding + 1,
|
||||
wgpu::BindingType::Sampler(if src == Source::Shadow {
|
||||
wgpu::SamplerBindingType::Comparison
|
||||
} else if filtering {
|
||||
wgpu::SamplerBindingType::Filtering
|
||||
} else {
|
||||
wgpu::SamplerBindingType::NonFiltering
|
||||
}),
|
||||
));
|
||||
samplers.push(Binding {
|
||||
source: src,
|
||||
binding: s.binding,
|
||||
filtering,
|
||||
});
|
||||
}
|
||||
let final_pass = p.group == Group::Final;
|
||||
let mut targets = Vec::new();
|
||||
let mut writes = Vec::new();
|
||||
for &location in &interface.outputs {
|
||||
let l = location as usize;
|
||||
if targets.len() <= l {
|
||||
targets.resize(l + 1, None);
|
||||
}
|
||||
if final_pass {
|
||||
if location == 0 {
|
||||
targets[0] = target(screen);
|
||||
}
|
||||
} else if let Some(&buffer) = p.buffers.get(l) {
|
||||
targets[l] = target(formats[buffer as usize]);
|
||||
writes.push((location, buffer as usize));
|
||||
}
|
||||
}
|
||||
if !final_pass && writes.is_empty() {
|
||||
notes.push(format!("{}: writes no buffer; skipped", p.name));
|
||||
continue;
|
||||
}
|
||||
let written = writes.iter().fold(0u16, |m, w| m | 1 << w.1);
|
||||
let kept = p
|
||||
.flips
|
||||
.iter()
|
||||
.filter(|f| !f.1)
|
||||
.fold(0u16, |m, f| m | 1 << f.0);
|
||||
let flip = if final_pass {
|
||||
0
|
||||
} else {
|
||||
(written & !kept) | mask(&p.flips)
|
||||
};
|
||||
used |= reads | written | flip;
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some(&p.name),
|
||||
entries: &entries,
|
||||
});
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some(&p.name),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
..Default::default()
|
||||
});
|
||||
let created = checked(device, || {
|
||||
let module = |m: naga::Module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(&p.name),
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
|
||||
})
|
||||
};
|
||||
let (vs, fs) = (module(p.program.vertex), module(p.program.fragment));
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(&p.name),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &targets,
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
});
|
||||
let pipeline = match created {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
notes.push(format!("{}: skipped: {e}", p.name));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let interface = &p.program.interface;
|
||||
passes.push(Pass {
|
||||
ubo: device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(&p.name),
|
||||
size: u64::from(interface.uniform_size.max(16)),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: vec![0; interface.uniform_size as usize],
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.map(|u| (pack_uniforms::index(&u.name), u.ty.clone(), u.offset))
|
||||
.collect(),
|
||||
name: p.name,
|
||||
group: p.group,
|
||||
pipeline,
|
||||
layout,
|
||||
samplers,
|
||||
writes,
|
||||
flip,
|
||||
reads,
|
||||
written,
|
||||
groups: HashMap::new(),
|
||||
});
|
||||
}
|
||||
// A clear only matters when a program reads the buffer before one writes all of it
|
||||
// (composite passes cover their whole target); the others are skipped.
|
||||
let mut cleared = Vec::new();
|
||||
for b in 1..BUFFERS {
|
||||
let bit = 1u16 << b;
|
||||
let first = passes
|
||||
.iter()
|
||||
.find(|p| (p.reads | p.flip | p.written) & bit != 0);
|
||||
if clears[b].is_some()
|
||||
&& first.is_some_and(|p| p.reads & bit != 0 || p.written & bit == 0)
|
||||
{
|
||||
cleared.push(b);
|
||||
}
|
||||
}
|
||||
let blit_depth = passes
|
||||
.iter()
|
||||
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth));
|
||||
let room = 8 - 1 - usize::from(blit_depth);
|
||||
let other_clears = cleared.split_off(room.min(cleared.len()));
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut blit_targets = vec![target(formats[0])];
|
||||
if blit_depth {
|
||||
blit_targets.push(target(wgpu::TextureFormat::R32Float));
|
||||
}
|
||||
// Cleared by the pass's load operation; the shader writes nothing there.
|
||||
blit_targets.extend(cleared.iter().map(|&b| {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format: formats[b],
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::empty(),
|
||||
})
|
||||
}));
|
||||
let blit = helper(
|
||||
device,
|
||||
"vs_full",
|
||||
if blit_depth {
|
||||
"fs_blit"
|
||||
} else {
|
||||
"fs_blit_color"
|
||||
},
|
||||
&[
|
||||
texture_entry(0, float),
|
||||
texture_entry(1, wgpu::TextureSampleType::Depth),
|
||||
],
|
||||
&blit_targets,
|
||||
);
|
||||
let copy = helper(
|
||||
device,
|
||||
"vs_uv",
|
||||
"fs_copy",
|
||||
&[
|
||||
texture_entry(0, float),
|
||||
entry(
|
||||
2,
|
||||
wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||
),
|
||||
],
|
||||
&[target(screen)],
|
||||
);
|
||||
let white = [255u8; 4];
|
||||
Self {
|
||||
notes,
|
||||
passes,
|
||||
deferred_pre: mask(&post.deferred_pre),
|
||||
composite_pre: mask(&post.composite_pre),
|
||||
formats,
|
||||
clears,
|
||||
used,
|
||||
blit_depth,
|
||||
blit_clears: cleared,
|
||||
other_clears,
|
||||
blit_group: None,
|
||||
main: 0,
|
||||
targets: None,
|
||||
noise: noise(device, queue),
|
||||
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
|
||||
shadow: (
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default()),
|
||||
device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("Shader pack shadow"),
|
||||
compare: Some(wgpu::CompareFunction::LessEqual),
|
||||
..Default::default()
|
||||
}),
|
||||
),
|
||||
shadow_cleared: false,
|
||||
linear: sampler(
|
||||
device,
|
||||
wgpu::FilterMode::Linear,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
),
|
||||
nearest: sampler(
|
||||
device,
|
||||
wgpu::FilterMode::Nearest,
|
||||
wgpu::AddressMode::ClampToEdge,
|
||||
),
|
||||
repeat: sampler(device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat),
|
||||
blit,
|
||||
copy,
|
||||
uniforms: FrameUniforms::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The names of the programs that run, in order.
|
||||
pub fn programs(&self) -> Vec<&str> {
|
||||
self.passes.iter().map(|p| p.name.as_str()).collect()
|
||||
}
|
||||
|
||||
fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
|
||||
if self.targets.as_ref().is_some_and(|t| t.size == size) {
|
||||
return;
|
||||
}
|
||||
let make = |label: &str, format| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size[0],
|
||||
height: size[1],
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
|
||||
| wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
let color = std::array::from_fn(|i| {
|
||||
(self.used & 1 << i != 0).then(|| {
|
||||
let label = format!("colortex{i}");
|
||||
[make(&label, self.formats[i]), make(&label, self.formats[i])]
|
||||
})
|
||||
});
|
||||
self.targets = Some(Targets {
|
||||
size,
|
||||
color,
|
||||
depth: make("depthtex0", wgpu::TextureFormat::R32Float),
|
||||
});
|
||||
for pass in &mut self.passes {
|
||||
pass.groups.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws
|
||||
/// the result onto `screen`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
world: &wgpu::TextureView,
|
||||
world_depth: &wgpu::TextureView,
|
||||
screen: &wgpu::TextureView,
|
||||
frame: &super::Frame,
|
||||
inputs: &PackInputs,
|
||||
size: [u32; 2],
|
||||
window: [u32; 2],
|
||||
) {
|
||||
self.resize(device, size);
|
||||
self.uniforms.update(frame, inputs, size);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
targets.color[i]
|
||||
.as_ref()
|
||||
.map(|t| &t[main(m, i) ^ usize::from(alt)])
|
||||
};
|
||||
|
||||
if !self.shadow_cleared {
|
||||
self.shadow_cleared = true;
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack shadow stand-in"),
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.shadow.0,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let clear_op = |b: usize| {
|
||||
let c = self.clears[b].unwrap_or_default();
|
||||
wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color {
|
||||
r: c[0].into(),
|
||||
g: c[1].into(),
|
||||
b: c[2].into(),
|
||||
a: c[3].into(),
|
||||
}),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}
|
||||
};
|
||||
// Overwritten whole: clearing lets a tiled GPU skip loading the old content.
|
||||
let overwrite = wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
};
|
||||
for chunk in self.other_clears.chunks(8) {
|
||||
let attachments: Vec<_> = chunk
|
||||
.iter()
|
||||
.map(|&b| attach(view(self.main, b, false).expect("used buffer"), clear_op(b)))
|
||||
.collect();
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack clear"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
// The world into colortex0 and depthtex0, flipped to GL's rows, and the clears.
|
||||
if self.blit_group.as_ref().is_none_or(|g| g.0 != size) {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack world"),
|
||||
layout: &self.blit.1,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(world),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(world_depth),
|
||||
},
|
||||
],
|
||||
});
|
||||
self.blit_group = Some((size, group));
|
||||
}
|
||||
{
|
||||
let mut attachments = vec![attach(
|
||||
view(self.main, 0, false).expect("colortex0"),
|
||||
overwrite,
|
||||
)];
|
||||
if self.blit_depth {
|
||||
attachments.push(attach(&targets.depth, overwrite));
|
||||
}
|
||||
for &b in &self.blit_clears {
|
||||
attachments.push(attach(
|
||||
view(self.main, b, false).expect("used buffer"),
|
||||
clear_op(b),
|
||||
));
|
||||
}
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack world"),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.blit.0);
|
||||
pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
let fog = self.uniforms.fog();
|
||||
let mut seen = (false, false);
|
||||
let mut shown = false;
|
||||
for pass in &mut self.passes {
|
||||
if pass.group == Group::Deferred && !seen.0 {
|
||||
seen.0 = true;
|
||||
self.main ^= self.deferred_pre;
|
||||
}
|
||||
if pass.group == Group::Composite && !seen.1 {
|
||||
seen.1 = true;
|
||||
self.main ^= self.composite_pre;
|
||||
}
|
||||
let final_pass = pass.group == Group::Final;
|
||||
let out = if final_pass { window } else { size };
|
||||
// Our block members first (`hz_target`, `hz_fog_color`, `hz_fog`), then the pack's.
|
||||
let ours = [
|
||||
[
|
||||
out[0] as f32,
|
||||
out[1] as f32,
|
||||
1.0 / out[0] as f32,
|
||||
1.0 / out[1] as f32,
|
||||
],
|
||||
fog[0].to_array(),
|
||||
fog[1].to_array(),
|
||||
];
|
||||
let head: &[u8] = bytemuck::cast_slice(&ours);
|
||||
let n = head.len().min(pass.block.len());
|
||||
pass.block[..n].copy_from_slice(&head[..n]);
|
||||
for (index, ty, offset) in &pass.uniforms {
|
||||
if let Some(v) = index.and_then(|i| self.uniforms.values.get(i)) {
|
||||
v.write(ty, &mut pass.block, *offset as usize);
|
||||
}
|
||||
}
|
||||
if !pass.block.is_empty() {
|
||||
queue.write_buffer(&pass.ubo, 0, &pass.block);
|
||||
}
|
||||
let key = self.main & pass.reads;
|
||||
let group = pass.groups.entry(key).or_insert_with(|| {
|
||||
let mut entries = vec![wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: pass.ubo.as_entire_binding(),
|
||||
}];
|
||||
for s in &pass.samplers {
|
||||
let filter = if s.filtering {
|
||||
&self.linear
|
||||
} else {
|
||||
&self.nearest
|
||||
};
|
||||
let (texture, sampler) = match s.source {
|
||||
Source::Color(i) => (view(key, i, false).unwrap_or(&self.dummy), filter),
|
||||
Source::Depth => (&targets.depth, &self.nearest),
|
||||
Source::Noise => (&self.noise, &self.repeat),
|
||||
Source::Dummy => (&self.dummy, &self.linear),
|
||||
Source::Shadow => (&self.shadow.0, &self.shadow.1),
|
||||
};
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: s.binding,
|
||||
resource: wgpu::BindingResource::TextureView(texture),
|
||||
});
|
||||
entries.push(wgpu::BindGroupEntry {
|
||||
binding: s.binding + 1,
|
||||
resource: wgpu::BindingResource::Sampler(sampler),
|
||||
});
|
||||
}
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some(&pass.name),
|
||||
layout: &pass.layout,
|
||||
entries: &entries,
|
||||
})
|
||||
});
|
||||
let mut attachments: Vec<Option<wgpu::RenderPassColorAttachment>> = Vec::new();
|
||||
if final_pass {
|
||||
attachments.push(attach(screen, overwrite));
|
||||
shown = true;
|
||||
} else {
|
||||
for &(location, buffer) in &pass.writes {
|
||||
let l = location as usize;
|
||||
if attachments.len() <= l {
|
||||
attachments.resize(l + 1, None);
|
||||
}
|
||||
attachments[l] =
|
||||
view(self.main, buffer, true).and_then(|v| attach(v, overwrite));
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some(&pass.name),
|
||||
color_attachments: &attachments,
|
||||
..Default::default()
|
||||
});
|
||||
rp.set_pipeline(&pass.pipeline);
|
||||
rp.set_bind_group(0, &*group, &[]);
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
self.main ^= pass.flip;
|
||||
}
|
||||
if !shown {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
layout: &self.copy.1,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
view(self.main, 0, false).expect("colortex0"),
|
||||
),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: wgpu::BindingResource::Sampler(&self.linear),
|
||||
},
|
||||
],
|
||||
});
|
||||
let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
color_attachments: &[attach(screen, overwrite)],
|
||||
..Default::default()
|
||||
});
|
||||
rp.set_pipeline(&self.copy.0);
|
||||
rp.set_bind_group(0, &group, &[]);
|
||||
rp.draw(0..3, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wgpu::TextureFormat as T;
|
||||
|
||||
#[test]
|
||||
fn pack_formats_map_to_renderable_ones() {
|
||||
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
|
||||
assert_eq!(f("RGBA"), T::Rgba8Unorm);
|
||||
assert_eq!(f("RGB8"), T::Rgba8Unorm, "no three-channel formats in wgpu");
|
||||
assert_eq!(f("RG16F"), T::Rg16Float);
|
||||
assert_eq!(f("R32F"), T::R32Float);
|
||||
assert_eq!(f("RGB10_A2"), T::Rgb10a2Unorm);
|
||||
assert_eq!(f("R32UI"), T::R32Uint);
|
||||
assert_eq!(f("RGBA16"), T::Rgba16Float, "16-bit norm needs a feature");
|
||||
assert_eq!(f("RGBA8_SNORM"), T::Rgba16Float, "snorm is not renderable");
|
||||
assert_eq!(f("R11F_G11F_B10F"), T::Rgba16Float);
|
||||
let all =
|
||||
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM | wgpu::Features::RG11B10UFLOAT_RENDERABLE;
|
||||
let g = |name| texture_format(Format::parse(name).unwrap(), all);
|
||||
assert_eq!(g("RGBA16"), T::Rgba16Unorm);
|
||||
assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampler_names() {
|
||||
assert!(source("gaux1") == Source::Color(4));
|
||||
assert!(source("gdepthtex") == Source::Depth);
|
||||
assert!(source("noisetex") == Source::Noise);
|
||||
assert!(source("shadowcolor0") == Source::Dummy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Helpers of a shader pack's post-processing (pack_post.rs): the world into colortex0 and
|
||||
// depthtex0, and colortex0 onto the screen for packs without `final`.
|
||||
|
||||
@vertex
|
||||
fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
return vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var world_depth: texture_depth_2d;
|
||||
|
||||
struct World {
|
||||
@location(0) color: vec4<f32>,
|
||||
@location(1) depth: vec4<f32>,
|
||||
}
|
||||
|
||||
// Pack buffers keep GL's rows (row 0 at the bottom), and GL's window depth: Horizon's
|
||||
// reverse-Z depth is 1 - d for the same near and far planes.
|
||||
@fragment
|
||||
fn fs_blit(@builtin(position) p: vec4<f32>) -> World {
|
||||
let size = vec2<i32>(textureDimensions(source));
|
||||
let at = vec2<i32>(i32(p.x), size.y - 1 - i32(p.y));
|
||||
var out: World;
|
||||
out.color = textureLoad(source, at, 0);
|
||||
out.depth = vec4<f32>(1.0 - textureLoad(world_depth, at, 0), 0.0, 0.0, 1.0);
|
||||
return out;
|
||||
}
|
||||
|
||||
// The same without depthtex0, for packs that never read it.
|
||||
@fragment
|
||||
fn fs_blit_color(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let size = vec2<i32>(textureDimensions(source));
|
||||
return textureLoad(source, vec2<i32>(i32(p.x), size.y - 1 - i32(p.y)), 0);
|
||||
}
|
||||
|
||||
@group(0) @binding(2) var linear: sampler;
|
||||
|
||||
struct Full {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_uv(@builtin(vertex_index) i: u32) -> Full {
|
||||
let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
// Screen top (uv.y = 0 in wgpu) shows the buffer's top row, GL's last.
|
||||
return Full(vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2<f32>(uv.x, 1.0 - uv.y));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_copy(v: Full) -> @location(0) vec4<f32> {
|
||||
return textureSample(source, linear, v.uv);
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! The uniforms a shader pack's post-processing programs read once per frame (spec 5.2, class
|
||||
//! F), from the renderer's `Frame` and the few values only the game knows (`PackInputs`).
|
||||
//! Matrices follow GL's conventions (5.5): `gbufferProjection` maps view depth to clip z in
|
||||
//! [-w, w], and the depth buffers packs read hold GL window depth.
|
||||
|
||||
use glam::{Mat4, Vec3, Vec4};
|
||||
|
||||
use super::Frame;
|
||||
|
||||
/// What the game passes for the pack's uniforms, beyond what `Frame` carries.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PackInputs {
|
||||
/// `Level.getDayTime`.
|
||||
pub day_time: i64,
|
||||
/// The interpolated rain and thunder levels, 0..1.
|
||||
pub rain: f32,
|
||||
pub thunder: f32,
|
||||
/// The fluid at the camera: 0 none, 1 water, 2 lava, 3 powder snow.
|
||||
pub eye_in: i32,
|
||||
pub blindness: f32,
|
||||
/// Block and sky light at the camera, 0..15.
|
||||
pub eye_light: [i32; 2],
|
||||
pub hide_gui: bool,
|
||||
/// The brightness option, 0..1.
|
||||
pub brightness: f32,
|
||||
}
|
||||
|
||||
/// One uniform's value: up to 16 floats or 3 integers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Value {
|
||||
F([f32; 16]),
|
||||
I([i32; 3]),
|
||||
}
|
||||
|
||||
impl Value {
|
||||
fn f(v: &[f32]) -> Self {
|
||||
let mut a = [0.0; 16];
|
||||
a[..v.len()].copy_from_slice(v);
|
||||
Value::F(a)
|
||||
}
|
||||
fn i(v: &[i32]) -> Self {
|
||||
let mut a = [0; 3];
|
||||
a[..v.len()].copy_from_slice(v);
|
||||
Value::I(a)
|
||||
}
|
||||
|
||||
/// Writes the value as the type the pack declared, at `offset` of a std140 block.
|
||||
pub fn write(&self, ty: &str, out: &mut [u8], offset: usize) {
|
||||
let ints = matches!(ty, "int" | "bool" | "uint") || ty.starts_with(['i', 'u']);
|
||||
let (floats, integers): ([f32; 16], [i32; 16]) = match *self {
|
||||
Value::F(f) => (f, f.map(|v| v as i32)),
|
||||
Value::I(i) => {
|
||||
let mut f = [0.0; 16];
|
||||
let mut v = [0; 16];
|
||||
for k in 0..3 {
|
||||
f[k] = i[k] as f32;
|
||||
v[k] = i[k];
|
||||
}
|
||||
(f, v)
|
||||
}
|
||||
};
|
||||
let count = match ty {
|
||||
"mat4" => 16,
|
||||
"mat3" => {
|
||||
// std140: three columns, each padded to a vec4, from the mat4's upper left.
|
||||
for c in 0..3 {
|
||||
let column: [f32; 3] = std::array::from_fn(|r| floats[c * 4 + r]);
|
||||
put(out, offset + c * 16, bytemuck::cast_slice(&column));
|
||||
}
|
||||
return;
|
||||
}
|
||||
t => match t.as_bytes().last() {
|
||||
Some(n @ b'2'..=b'4') if t.contains("vec") => (n - b'0') as usize,
|
||||
_ => 1,
|
||||
},
|
||||
};
|
||||
if ints {
|
||||
put(out, offset, bytemuck::cast_slice(&integers[..count]));
|
||||
} else {
|
||||
put(out, offset, bytemuck::cast_slice(&floats[..count]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn put(out: &mut [u8], offset: usize, bytes: &[u8]) {
|
||||
if let Some(dst) = out.get_mut(offset..offset + bytes.len()) {
|
||||
dst.copy_from_slice(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The uniforms provided, in the order of [`FrameUniforms::values`].
|
||||
pub const NAMES: &[&str] = &[
|
||||
"cameraPosition",
|
||||
"previousCameraPosition",
|
||||
"eyeAltitude",
|
||||
"upPosition",
|
||||
"sunPosition",
|
||||
"moonPosition",
|
||||
"shadowLightPosition",
|
||||
"sunAngle",
|
||||
"shadowAngle",
|
||||
"worldTime",
|
||||
"worldDay",
|
||||
"moonPhase",
|
||||
"frameCounter",
|
||||
"frameTime",
|
||||
"frameTimeCounter",
|
||||
"viewWidth",
|
||||
"viewHeight",
|
||||
"aspectRatio",
|
||||
"near",
|
||||
"far",
|
||||
"rainStrength",
|
||||
"wetness",
|
||||
"thunderStrength",
|
||||
"fogColor",
|
||||
"skyColor",
|
||||
"fogMode",
|
||||
"fogStart",
|
||||
"fogEnd",
|
||||
"fogDensity",
|
||||
"fogShape",
|
||||
"isEyeInWater",
|
||||
"blindness",
|
||||
"nightVision",
|
||||
"darknessFactor",
|
||||
"darknessLightFactor",
|
||||
"screenBrightness",
|
||||
"hideGUI",
|
||||
"eyeBrightness",
|
||||
"eyeBrightnessSmooth",
|
||||
"heldItemId",
|
||||
"heldItemId2",
|
||||
"heldBlockLightValue",
|
||||
"heldBlockLightValue2",
|
||||
"renderStage",
|
||||
"gbufferModelView",
|
||||
"gbufferModelViewInverse",
|
||||
"gbufferProjection",
|
||||
"gbufferProjectionInverse",
|
||||
"gbufferPreviousModelView",
|
||||
"gbufferPreviousProjection",
|
||||
];
|
||||
|
||||
/// Values carried from frame to frame.
|
||||
pub struct FrameUniforms {
|
||||
pub values: Vec<Value>,
|
||||
start: std::time::Instant,
|
||||
last: Option<std::time::Instant>,
|
||||
frame: u32,
|
||||
previous: Option<(Vec3, Mat4, Mat4)>,
|
||||
wetness: f32,
|
||||
eye_smooth: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for FrameUniforms {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
values: Vec::new(),
|
||||
start: std::time::Instant::now(),
|
||||
last: None,
|
||||
frame: 0,
|
||||
previous: None,
|
||||
wetness: 0.0,
|
||||
eye_smooth: [0.0; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(0, 100, 0)` on the frame the sky draws a body on: Y -90°, then X by its angle in degrees.
|
||||
fn body(angle: f32) -> Vec3 {
|
||||
(Mat4::from_rotation_y((-90.0f32).to_radians()) * Mat4::from_rotation_x(angle.to_radians()))
|
||||
.transform_vector3(Vec3::new(0.0, 100.0, 0.0))
|
||||
}
|
||||
|
||||
/// `value` moved toward `target` with a half-life of `half_life` seconds over `dt` (5.6).
|
||||
fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
|
||||
target + (value - target) * 0.5f32.powf(dt / half_life)
|
||||
}
|
||||
|
||||
impl FrameUniforms {
|
||||
/// This frame's values for a render size of `size` pixels.
|
||||
pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) {
|
||||
let now = std::time::Instant::now();
|
||||
let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32());
|
||||
self.last = Some(now);
|
||||
let aspect = size[0] as f32 / size[1].max(1) as f32;
|
||||
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
|
||||
let projection = Mat4::perspective_rh_gl(
|
||||
frame.fov_degrees.to_radians(),
|
||||
aspect,
|
||||
0.05,
|
||||
frame.depth_far,
|
||||
) * frame.bob;
|
||||
let camera = frame.camera.as_vec3();
|
||||
let (previous_camera, previous_view, previous_projection) =
|
||||
self.previous
|
||||
.unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO));
|
||||
self.previous = Some((camera, view, projection));
|
||||
|
||||
let to_view = |v: Vec3| view.transform_vector3(v);
|
||||
let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0);
|
||||
let day = sun_angle < 0.5;
|
||||
let sun = to_view(body(frame.sky.sun_angle));
|
||||
let moon = to_view(body(frame.sky.moon_angle));
|
||||
// Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26).
|
||||
let half_life = if inputs.rain > self.wetness {
|
||||
600.0
|
||||
} else {
|
||||
200.0
|
||||
};
|
||||
self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt);
|
||||
let eye = inputs.eye_light.map(|l| (l * 16) as f32);
|
||||
for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) {
|
||||
*smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt);
|
||||
}
|
||||
let (fog_color, fog_start, fog_end) = match &frame.fog {
|
||||
Some(f) => (
|
||||
[f.color[0], f.color[1], f.color[2]],
|
||||
f.environmental_start.min(f.render_distance_start),
|
||||
f.environmental_end.min(f.render_distance_end),
|
||||
),
|
||||
None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance),
|
||||
};
|
||||
let day_time = inputs.day_time.rem_euclid(24000) as i32;
|
||||
let world_day = inputs.day_time.div_euclid(24000);
|
||||
let seconds = (now - self.start).as_secs_f32() % 3600.0;
|
||||
|
||||
let value = |name: &str| -> Value {
|
||||
let v3 = |v: Vec3| Value::f(&v.to_array());
|
||||
let m4 = |m: Mat4| Value::f(&m.to_cols_array());
|
||||
match name {
|
||||
"cameraPosition" => v3(camera),
|
||||
"previousCameraPosition" => v3(previous_camera),
|
||||
"eyeAltitude" => Value::f(&[frame.camera.y as f32]),
|
||||
"upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))),
|
||||
"sunPosition" => v3(sun),
|
||||
"moonPosition" => v3(moon),
|
||||
"shadowLightPosition" => v3(if day { sun } else { moon }),
|
||||
"sunAngle" => Value::f(&[sun_angle]),
|
||||
"shadowAngle" => Value::f(&[if day { sun_angle } else { sun_angle - 0.5 }]),
|
||||
"worldTime" => Value::i(&[day_time]),
|
||||
"worldDay" => Value::i(&[world_day as i32]),
|
||||
"moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]),
|
||||
"frameCounter" => Value::i(&[self.frame as i32]),
|
||||
"frameTime" => Value::f(&[dt]),
|
||||
"frameTimeCounter" => Value::f(&[seconds]),
|
||||
"viewWidth" => Value::f(&[size[0] as f32]),
|
||||
"viewHeight" => Value::f(&[size[1] as f32]),
|
||||
"aspectRatio" => Value::f(&[aspect]),
|
||||
"near" => Value::f(&[0.05]),
|
||||
"far" => Value::f(&[frame.distance]),
|
||||
"rainStrength" => Value::f(&[inputs.rain]),
|
||||
"wetness" => Value::f(&[self.wetness]),
|
||||
"thunderStrength" => Value::f(&[inputs.thunder]),
|
||||
"fogColor" => Value::f(&fog_color),
|
||||
"skyColor" => Value::f(&frame.sky.sky_color),
|
||||
// GL_LINEAR: vanilla's fog is linear.
|
||||
"fogMode" => Value::i(&[9729]),
|
||||
"fogStart" => Value::f(&[fog_start]),
|
||||
"fogEnd" => Value::f(&[fog_end]),
|
||||
"fogDensity" => Value::f(&[1.0]),
|
||||
"fogShape" => Value::i(&[0]),
|
||||
"isEyeInWater" => Value::i(&[inputs.eye_in]),
|
||||
"blindness" => Value::f(&[inputs.blindness]),
|
||||
"nightVision" => Value::f(&[frame.night_vision]),
|
||||
"darknessFactor" => Value::f(&[frame.darkness.0]),
|
||||
"darknessLightFactor" => Value::f(&[frame.darkness.1]),
|
||||
"screenBrightness" => Value::f(&[inputs.brightness]),
|
||||
"hideGUI" => Value::i(&[inputs.hide_gui as i32]),
|
||||
"eyeBrightness" => Value::i(&eye.map(|v| v as i32)),
|
||||
"eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.round() as i32)),
|
||||
"gbufferModelView" => m4(view),
|
||||
"gbufferModelViewInverse" => m4(view.inverse()),
|
||||
"gbufferProjection" => m4(projection),
|
||||
"gbufferProjectionInverse" => m4(projection.inverse()),
|
||||
"gbufferPreviousModelView" => m4(previous_view),
|
||||
"gbufferPreviousProjection" => m4(previous_projection),
|
||||
// Held items wait for `item.properties` (5.3: 0 without the file); renderStage
|
||||
// is NONE outside gbuffers draws.
|
||||
_ => Value::i(&[0]),
|
||||
}
|
||||
};
|
||||
self.values = NAMES.iter().map(|n| value(n)).collect();
|
||||
self.frame = (self.frame + 1) % 720_720;
|
||||
}
|
||||
|
||||
/// `hz_fog_color` and `hz_fog` of the translator's block (the compatibility `gl_Fog`).
|
||||
pub fn fog(&self) -> [Vec4; 2] {
|
||||
let get = |name| match index(name).and_then(|i| self.values.get(i)) {
|
||||
Some(Value::F(f)) => *f,
|
||||
_ => [0.0; 16],
|
||||
};
|
||||
let (color, start, end) = (get("fogColor"), get("fogStart")[0], get("fogEnd")[0]);
|
||||
[
|
||||
Vec4::new(color[0], color[1], color[2], 1.0),
|
||||
Vec4::new(1.0, start, end, 1.0 / (end - start).max(1e-3)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of `name` in [`NAMES`].
|
||||
pub fn index(name: &str) -> Option<usize> {
|
||||
NAMES.iter().position(|n| *n == name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn values_follow_the_declared_type() {
|
||||
let mut out = vec![0u8; 64];
|
||||
Value::i(&[7]).write("float", &mut out, 0);
|
||||
assert_eq!(f32::from_ne_bytes(out[0..4].try_into().unwrap()), 7.0);
|
||||
Value::f(&[1.5, 2.5]).write("ivec2", &mut out, 4);
|
||||
assert_eq!(i32::from_ne_bytes(out[8..12].try_into().unwrap()), 2);
|
||||
Value::i(&[1]).write("bool", &mut out, 16);
|
||||
assert_eq!(i32::from_ne_bytes(out[16..20].try_into().unwrap()), 1);
|
||||
Value::f(&[1.0; 16]).write("mat4", &mut out, 60);
|
||||
assert_eq!(out[60..64], [0; 4], "a value past the block is dropped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
|
||||
assert!(body(0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
|
||||
assert!(body(90.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
|
||||
}
|
||||
}
|
||||
@@ -101,12 +101,14 @@ impl Post {
|
||||
|
||||
/// The world's colour and depth targets this frame, or `None` to draw
|
||||
/// it straight to the screen.
|
||||
/// `always`: a target even at scale 1 without FXAA (a shader pack reads it).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
window: [u32; 2],
|
||||
always: bool,
|
||||
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
|
||||
if self.scale == 1.0 && !self.antialias {
|
||||
if self.scale == 1.0 && !self.antialias && !always {
|
||||
self.target = None;
|
||||
return None;
|
||||
}
|
||||
@@ -155,6 +157,11 @@ impl Post {
|
||||
.map(|t| (t.color.clone(), t.depth.clone()))
|
||||
}
|
||||
|
||||
/// The size of the world's own target, when it has one.
|
||||
pub fn size(&self) -> Option<[u32; 2]> {
|
||||
self.target.as_ref().map(|t| t.size)
|
||||
}
|
||||
|
||||
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
|
||||
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
|
||||
@@ -78,7 +78,10 @@ pub const MAP_SLOTS: usize = 256;
|
||||
/// The atlas slot the minimap extra draws into.
|
||||
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
|
||||
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
|
||||
pub const SKIN_SLOTS: usize = 32;
|
||||
/// Vanilla registers a texture per skin. Here a skin evicted from its slot
|
||||
/// is not stored again, and its player falls back to the default skin, so
|
||||
/// the slots must cover a busy server's player list.
|
||||
pub const SKIN_SLOTS: usize = 128;
|
||||
/// Reused by the visible rows of the classic world selector.
|
||||
pub const WORLD_ICON_SLOTS: usize = 32;
|
||||
/// Atlas slots for the pack icons on the pack screen, one per icon drawn
|
||||
|
||||
@@ -1984,7 +1984,9 @@ impl Hub {
|
||||
S::PlayerInput(p) => {
|
||||
self.guests[i].input = p.flags;
|
||||
self.guests[i].sneaking = p.flags & 32 != 0;
|
||||
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
|
||||
// handlePlayerInput only records keys and updates crouching.
|
||||
// Sprinting persists after the sprint key is released (or
|
||||
// starts with a double tap); PlayerCommand changes it.
|
||||
}
|
||||
// OPEN_INVENTORY (the ridden mount's inventory) and
|
||||
// START_FALL_FLYING (checked against the guest's chest slot
|
||||
|
||||
@@ -37,6 +37,21 @@ pub trait ResourceSource {
|
||||
vec!["minecraft".into()]
|
||||
}
|
||||
}
|
||||
/// Horizon's own strings, compiled in so they need no prepared resources.
|
||||
struct Bundled;
|
||||
impl ResourceSource for Bundled {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
let body = match path {
|
||||
"assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"),
|
||||
"assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(body.as_bytes().to_vec())
|
||||
}
|
||||
fn namespaces(&self) -> Vec<String> {
|
||||
vec!["horizon".into()]
|
||||
}
|
||||
}
|
||||
impl ResourceSource for std::path::PathBuf {
|
||||
fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
std::fs::read(self.join(path)).ok()
|
||||
@@ -86,6 +101,12 @@ impl Language {
|
||||
if code != DEFAULT {
|
||||
codes.push(code);
|
||||
}
|
||||
// Horizon's own strings first, so a pack or the prepared root can still
|
||||
// override them. Vanilla has no key for "Controller vibration".
|
||||
let bundled = Bundled;
|
||||
let mut all: Vec<&dyn ResourceSource> = vec![&bundled];
|
||||
all.extend_from_slice(sources);
|
||||
let sources = all.as_slice();
|
||||
let namespaces: std::collections::BTreeSet<String> =
|
||||
sources.iter().flat_map(|s| s.namespaces()).collect();
|
||||
for c in codes {
|
||||
@@ -204,6 +225,16 @@ mod tests {
|
||||
self.0.get(path).cloned()
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn bundled_horizon_strings_follow_the_language() {
|
||||
// No prepared resources at all: the namespace is compiled in.
|
||||
let empty = Mem(HashMap::new());
|
||||
let en = Language::load(&[&empty], "en_us");
|
||||
assert_eq!(en.get("horizon.options.controller"), Some("Controller"));
|
||||
let ru = Language::load(&[&empty], "ru_ru");
|
||||
assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_selected_language_over_english() {
|
||||
let base = Mem(HashMap::from([
|
||||
|
||||
@@ -54,6 +54,7 @@ pub mod datapack;
|
||||
pub mod debug_chart;
|
||||
pub mod debug_keys;
|
||||
pub mod debug_overlay;
|
||||
pub mod discord_presence;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod enchant;
|
||||
|
||||
@@ -1758,6 +1758,11 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> {
|
||||
state: BlockStateId,
|
||||
position: BlockPos,
|
||||
) -> Result<()> {
|
||||
// LivingEntity.aiStep: Slow Falling and Levitation reset the fall
|
||||
// distance every tick, so landing under either does not hurt.
|
||||
if self.player.status.slow_falling || self.player.status.levitation.is_some() {
|
||||
self.player.fall_distance = 0.0;
|
||||
}
|
||||
if self.player.spawn_extra_particles_on_fall && grounded && self.player.fall_distance > 0.0
|
||||
{
|
||||
self.player.events.push(MotionEvent::SmashLanding {
|
||||
@@ -2918,6 +2923,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn slow_falling_clears_the_fall_distance_so_landing_does_not_hurt() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
for slow_falling in [false, true] {
|
||||
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
|
||||
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 9.0, 20.5)).unwrap();
|
||||
player.fall_distance = 30.0;
|
||||
player.status.slow_falling = slow_falling;
|
||||
let mut w = MotionWorld {
|
||||
entities: &[],
|
||||
player: &mut player,
|
||||
scene: &mut scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
};
|
||||
for _ in 0..200 {
|
||||
if w.player.ground.on_ground {
|
||||
break;
|
||||
}
|
||||
w.player_movement_tick([0.0, 0.0], false).unwrap();
|
||||
}
|
||||
assert!(w.player.ground.on_ground);
|
||||
if slow_falling {
|
||||
assert_eq!(w.player.health.health(), 20.0);
|
||||
assert_eq!(w.player.fall_distance, 0.0);
|
||||
} else {
|
||||
assert!(w.player.health.health() < 20.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
|
||||
@@ -18,6 +18,7 @@ use horizon_protocol::{
|
||||
text::{Language, plain, translatable},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
@@ -27,6 +28,12 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// `CommonListenerCookie.serverCookies`: cookies the server asked the client
|
||||
/// to store (`ClientboundStoreCookiePacket`), answered to
|
||||
/// `ClientboundCookieRequestPacket`. A `transfer` carries them to the next
|
||||
/// connection (`TransferState`), so a proxy's backend switch keeps them.
|
||||
pub type Cookies = Arc<Mutex<HashMap<String, Vec<u8>>>>;
|
||||
|
||||
/// What the network thread reports to the game.
|
||||
pub enum Event {
|
||||
/// Progress text for the connecting screen.
|
||||
@@ -48,6 +55,13 @@ pub enum Event {
|
||||
PackPush(cfg::ResourcePackPush),
|
||||
/// ClientboundResourcePackPopPacket: one pack, or every pack.
|
||||
PackPop(Option<u128>),
|
||||
/// `ClientboundTransferPacket`: the server sent us to another address.
|
||||
/// The connection ends right after; the game reconnects there with the
|
||||
/// stored cookies.
|
||||
Transfer {
|
||||
host: String,
|
||||
port: i32,
|
||||
},
|
||||
/// The connection ended. `from_server`: the server gave the reason (or
|
||||
/// the connection was refused for good), rather than the connection
|
||||
/// failing (auto-reconnect retries only failures and shutdowns).
|
||||
@@ -67,6 +81,8 @@ pub struct Session {
|
||||
pub address: String,
|
||||
pub name: String,
|
||||
pub counters: Arc<Mutex<Option<Arc<Counters>>>>,
|
||||
/// The cookies this server stored on us (`ClientboundStoreCookiePacket`).
|
||||
pub cookies: Cookies,
|
||||
/// HORIZON_NET_DEBUG=1 logs every packet sent from the game thread.
|
||||
debug: bool,
|
||||
}
|
||||
@@ -79,7 +95,27 @@ impl Session {
|
||||
view_distance: i8,
|
||||
lang: Arc<Language>,
|
||||
) -> Self {
|
||||
Self::start(address, name, auth, view_distance, lang, None)
|
||||
Self::start(address, name, auth, view_distance, lang, None, None)
|
||||
}
|
||||
/// `ConnectScreen.startConnecting` for a `transfer`: the cookies the
|
||||
/// server stored carry to the new connection (`TransferState`).
|
||||
pub fn connect_transferred(
|
||||
address: &str,
|
||||
name: &str,
|
||||
auth: Option<Arc<AuthSession>>,
|
||||
view_distance: i8,
|
||||
lang: Arc<Language>,
|
||||
cookies: Cookies,
|
||||
) -> Self {
|
||||
Self::start(
|
||||
address,
|
||||
name,
|
||||
auth,
|
||||
view_distance,
|
||||
lang,
|
||||
None,
|
||||
Some(cookies),
|
||||
)
|
||||
}
|
||||
/// Join an integrated server through memory (`Connection.connectToLocalServer`).
|
||||
pub fn connect_local(
|
||||
@@ -88,7 +124,7 @@ impl Session {
|
||||
view_distance: i8,
|
||||
lang: Arc<Language>,
|
||||
) -> Self {
|
||||
Self::start("local", name, None, view_distance, lang, Some(pipe))
|
||||
Self::start("local", name, None, view_distance, lang, Some(pipe), None)
|
||||
}
|
||||
fn start(
|
||||
address: &str,
|
||||
@@ -97,12 +133,18 @@ impl Session {
|
||||
view_distance: i8,
|
||||
lang: Arc<Language>,
|
||||
local: Option<horizon_protocol::connection::Pipe>,
|
||||
cookies: Option<Cookies>,
|
||||
) -> Self {
|
||||
let (send, events) = channel();
|
||||
let writer = Arc::new(Mutex::new(None));
|
||||
let configuring = Arc::new(AtomicBool::new(true));
|
||||
let counters = Arc::new(Mutex::new(None));
|
||||
let (thread_writer, thread_counters) = (writer.clone(), counters.clone());
|
||||
// Connection.initiateServerboundPlayConnection: a transfer's
|
||||
// handshake says so (ClientIntent.TRANSFER).
|
||||
let transferred = cookies.is_some();
|
||||
let cookies = cookies.unwrap_or_default();
|
||||
let (thread_writer, thread_counters, thread_cookies) =
|
||||
(writer.clone(), counters.clone(), cookies.clone());
|
||||
let thread_configuring = configuring.clone();
|
||||
let (address_owned, name_owned) = (address.to_owned(), name.to_owned());
|
||||
let thread = std::thread::Builder::new()
|
||||
@@ -119,6 +161,8 @@ impl Session {
|
||||
&thread_writer,
|
||||
&thread_configuring,
|
||||
&thread_counters,
|
||||
&thread_cookies,
|
||||
transferred,
|
||||
);
|
||||
let from_server = result.is_ok();
|
||||
let reason = result.unwrap_or_else(|e| e);
|
||||
@@ -140,6 +184,7 @@ impl Session {
|
||||
address: address.to_owned(),
|
||||
name: name.to_owned(),
|
||||
counters,
|
||||
cookies,
|
||||
debug,
|
||||
}
|
||||
}
|
||||
@@ -291,6 +336,8 @@ fn run(
|
||||
shared_writer: &Mutex<Option<ConnWriter>>,
|
||||
configuring: &AtomicBool,
|
||||
shared_counters: &Mutex<Option<Arc<Counters>>>,
|
||||
cookies: &Cookies,
|
||||
transferred: bool,
|
||||
) -> Result<String, String> {
|
||||
let (host, port, mut c) = if let Some(pipe) = local {
|
||||
(address.to_owned(), 0, Connection::local(pipe))
|
||||
@@ -321,7 +368,11 @@ fn run(
|
||||
protocol: PROTOCOL_VERSION,
|
||||
host: host.clone(),
|
||||
port,
|
||||
intent: handshake::LOGIN,
|
||||
intent: if transferred {
|
||||
handshake::TRANSFER
|
||||
} else {
|
||||
handshake::LOGIN
|
||||
},
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
@@ -391,11 +442,14 @@ fn run(
|
||||
)
|
||||
.map_err(e)?;
|
||||
}
|
||||
// ClientHandshakePacketListenerImpl.handleRequestCookie: the
|
||||
// cookies a transfer carried over (none on a plain join).
|
||||
login::Clientbound::CookieRequest(q) => {
|
||||
let payload = cookies.lock().unwrap().get(&q.key).cloned();
|
||||
c.send(
|
||||
&login::Serverbound::CookieResponse(login::CookieResponse {
|
||||
key: q.key,
|
||||
payload: None,
|
||||
payload,
|
||||
})
|
||||
.encode(),
|
||||
)
|
||||
@@ -419,13 +473,13 @@ fn run(
|
||||
.map_err(e)?;
|
||||
let (mut reader, writer) = c.split();
|
||||
*shared_writer.lock().unwrap() = Some(writer);
|
||||
let registries = configure(&mut reader, shared_writer, configuring, lang, send)?;
|
||||
let registries = configure(&mut reader, shared_writer, configuring, lang, send, cookies)?;
|
||||
let _ = send.send(Event::Joined {
|
||||
registries: Arc::new(registries),
|
||||
uuid: profile.uuid,
|
||||
name: profile.name,
|
||||
});
|
||||
receive_play(&mut reader, shared_writer, configuring, lang, send)
|
||||
receive_play(&mut reader, shared_writer, configuring, lang, send, cookies)
|
||||
}
|
||||
|
||||
fn receive_play(
|
||||
@@ -434,6 +488,7 @@ fn receive_play(
|
||||
configuring: &AtomicBool,
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
cookies: &Cookies,
|
||||
) -> Result<String, String> {
|
||||
loop {
|
||||
let (id, payload) = reader.recv().map_err(|e| e.to_string())?;
|
||||
@@ -469,9 +524,31 @@ fn receive_play(
|
||||
let _ = w.send(&play::Serverbound::ConfigurationAcknowledged(Empty).encode());
|
||||
configuring.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let registries = configure(reader, shared_writer, configuring, lang, send)?;
|
||||
let registries =
|
||||
configure(reader, shared_writer, configuring, lang, send, cookies)?;
|
||||
let _ = send.send(Event::Reconfigured(Arc::new(registries)));
|
||||
}
|
||||
// ClientCommonPacketListenerImpl.handleRequestCookie: the stored
|
||||
// cookie (or none) goes straight back.
|
||||
play::Clientbound::CookieRequest(q) => {
|
||||
let payload = cookies.lock().unwrap().get(&q.key).cloned();
|
||||
reply(play::Serverbound::CookieResponse(cfg::CookieResponse {
|
||||
key: q.key,
|
||||
payload,
|
||||
}));
|
||||
}
|
||||
play::Clientbound::StoreCookie(p) => {
|
||||
cookies.lock().unwrap().insert(p.key, p.payload);
|
||||
}
|
||||
// ClientCommonPacketListenerImpl.handleTransfer: the connection
|
||||
// ends and the game connects to the other address.
|
||||
play::Clientbound::Transfer(p) => {
|
||||
let _ = send.send(Event::Transfer {
|
||||
host: p.host,
|
||||
port: p.port,
|
||||
});
|
||||
return Ok(tr(lang, "disconnect.transfer"));
|
||||
}
|
||||
play::Clientbound::Disconnect(d) => return Ok(plain(&d.reason, lang)),
|
||||
play::Clientbound::CustomPayload(p) if p.channel == "minecraft:brand" => {
|
||||
receive_brand(&p, send);
|
||||
@@ -493,6 +570,7 @@ fn configure(
|
||||
configuring: &AtomicBool,
|
||||
lang: &Language,
|
||||
send: &Sender<Event>,
|
||||
cookies: &Cookies,
|
||||
) -> Result<Dynamic, String> {
|
||||
let mut registries = Dynamic::default();
|
||||
let reply = |p: cfg::Serverbound| -> Result<(), String> {
|
||||
@@ -525,11 +603,22 @@ fn configure(
|
||||
let _ = send.send(Event::PackPop(p.id));
|
||||
}
|
||||
cfg::Clientbound::CookieRequest(q) => {
|
||||
let payload = cookies.lock().unwrap().get(&q.key).cloned();
|
||||
reply(cfg::Serverbound::CookieResponse(cfg::CookieResponse {
|
||||
key: q.key,
|
||||
payload: None,
|
||||
payload,
|
||||
}))?
|
||||
}
|
||||
cfg::Clientbound::StoreCookie(p) => {
|
||||
cookies.lock().unwrap().insert(p.key, p.payload);
|
||||
}
|
||||
cfg::Clientbound::Transfer(p) => {
|
||||
let _ = send.send(Event::Transfer {
|
||||
host: p.host,
|
||||
port: p.port,
|
||||
});
|
||||
return Err(tr(lang, "disconnect.transfer"));
|
||||
}
|
||||
cfg::Clientbound::Disconnect(d) => return Err(plain(&d.reason, lang)),
|
||||
cfg::Clientbound::FinishConfiguration(_) => {
|
||||
let mut w = writer.lock().unwrap();
|
||||
@@ -659,6 +748,7 @@ mod tests {
|
||||
&AtomicBool::new(false),
|
||||
&Language::new(),
|
||||
&send,
|
||||
&Cookies::default(),
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
@@ -708,6 +798,7 @@ mod tests {
|
||||
address: "local".into(),
|
||||
name: "Player".into(),
|
||||
counters: Arc::new(Mutex::new(None)),
|
||||
cookies: Cookies::default(),
|
||||
debug: false,
|
||||
};
|
||||
let (thread_writer, thread_configuring) =
|
||||
@@ -719,6 +810,7 @@ mod tests {
|
||||
&thread_configuring,
|
||||
&Language::new(),
|
||||
&send,
|
||||
&Cookies::default(),
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
@@ -749,6 +841,68 @@ mod tests {
|
||||
assert!(thread.join().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn play_answers_cookie_requests_from_stored_cookies_and_follows_transfers() {
|
||||
let (client, server) = horizon_protocol::connection::Pipe::pair();
|
||||
let (mut reader, writer) = Connection::local(client).split();
|
||||
let writer = Arc::new(Mutex::new(Some(writer)));
|
||||
let (send, events) = channel();
|
||||
let cookies = Cookies::default();
|
||||
let (thread_writer, thread_cookies) = (writer.clone(), cookies.clone());
|
||||
let thread = std::thread::spawn(move || {
|
||||
receive_play(
|
||||
&mut reader,
|
||||
&thread_writer,
|
||||
&AtomicBool::new(false),
|
||||
&Language::new(),
|
||||
&send,
|
||||
&thread_cookies,
|
||||
)
|
||||
});
|
||||
let mut server = Connection::local(server);
|
||||
let ask = |server: &mut Connection, key: &str| {
|
||||
let request = play::Clientbound::CookieRequest(cfg::CookieRequest { key: key.into() });
|
||||
server.send(&request.encode()).unwrap();
|
||||
let (id, body) = server.recv().unwrap();
|
||||
play::Serverbound::decode(id, &body).unwrap()
|
||||
};
|
||||
// handleRequestCookie answers an unknown key with no payload.
|
||||
assert_eq!(
|
||||
ask(&mut server, "proxy:session"),
|
||||
play::Serverbound::CookieResponse(cfg::CookieResponse {
|
||||
key: "proxy:session".into(),
|
||||
payload: None,
|
||||
})
|
||||
);
|
||||
let store = play::Clientbound::StoreCookie(cfg::StoreCookie {
|
||||
key: "proxy:session".into(),
|
||||
payload: vec![1, 2, 3],
|
||||
});
|
||||
server.send(&store.encode()).unwrap();
|
||||
assert_eq!(
|
||||
ask(&mut server, "proxy:session"),
|
||||
play::Serverbound::CookieResponse(cfg::CookieResponse {
|
||||
key: "proxy:session".into(),
|
||||
payload: Some(vec![1, 2, 3]),
|
||||
})
|
||||
);
|
||||
let transfer = play::Clientbound::Transfer(cfg::Transfer {
|
||||
host: "example.net".into(),
|
||||
port: 25566,
|
||||
});
|
||||
server.send(&transfer.encode()).unwrap();
|
||||
assert!(thread.join().unwrap().is_ok());
|
||||
assert!(matches!(
|
||||
events.try_iter().last(),
|
||||
Some(Event::Transfer { host, port: 25566 }) if host == "example.net"
|
||||
));
|
||||
// The stored cookie stays for the next connection (TransferState).
|
||||
assert_eq!(
|
||||
cookies.lock().unwrap().get("proxy:session"),
|
||||
Some(&vec![1, 2, 3])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_brand_payload_is_decoded_and_invalid_or_unrelated_payloads_are_ignored() {
|
||||
let (send, events) = channel();
|
||||
|
||||
@@ -267,20 +267,21 @@ impl Opt {
|
||||
Opt::HighContrastBlockOutlines => "options.accessibility.high_contrast_block_outline",
|
||||
Opt::ForceUnicode => "options.forceUnicodeFont",
|
||||
Opt::JapaneseGlyphs => "options.japaneseGlyphVariants",
|
||||
// Horizon's own options: English captions (no vanilla keys).
|
||||
Opt::PadEnabled => "Controller",
|
||||
Opt::PadVibration => "Vibration",
|
||||
Opt::PadAdaptiveTriggers => "Adaptive Triggers",
|
||||
Opt::PadLightbar => "Lightbar",
|
||||
Opt::PadLookSensitivity => "Look Sensitivity",
|
||||
Opt::PadInvertX => "Invert Look X",
|
||||
Opt::PadInvertY => "Invert Look Y",
|
||||
Opt::PadToggleSneak => "Sneak Button",
|
||||
Opt::PadMoveDeadzone => "Move Deadzone",
|
||||
Opt::PadLookDeadzone => "Look Deadzone",
|
||||
Opt::PadGlyphs => "Button Glyphs",
|
||||
Opt::PadMenuNav => "Menu Navigation",
|
||||
Opt::PadKeyboard => "On-Screen Keyboard",
|
||||
// Horizon's own options: vanilla has no keys for them, so they read
|
||||
// the namespace the client bundles (assets/horizon/lang).
|
||||
Opt::PadEnabled => "horizon.options.controller",
|
||||
Opt::PadVibration => "horizon.options.vibration",
|
||||
Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers",
|
||||
Opt::PadLightbar => "horizon.options.lightbar",
|
||||
Opt::PadLookSensitivity => "horizon.options.look_sensitivity",
|
||||
Opt::PadInvertX => "horizon.options.invert_x",
|
||||
Opt::PadInvertY => "horizon.options.invert_y",
|
||||
Opt::PadToggleSneak => "horizon.options.sneak_button",
|
||||
Opt::PadMoveDeadzone => "horizon.options.move_deadzone",
|
||||
Opt::PadLookDeadzone => "horizon.options.look_deadzone",
|
||||
Opt::PadGlyphs => "horizon.options.button_glyphs",
|
||||
Opt::PadMenuNav => "horizon.options.menu_navigation",
|
||||
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
|
||||
}
|
||||
}
|
||||
pub(crate) fn kind(self, s: &Settings) -> Kind {
|
||||
@@ -1291,7 +1292,10 @@ impl OptionsUi {
|
||||
open("Graphics...", Page::Graphics),
|
||||
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
|
||||
),
|
||||
Row::Buttons(open("Controller Settings...", Page::Controller), None),
|
||||
Row::Buttons(
|
||||
open("Controller Settings...", Page::Controller),
|
||||
Some(self.extra_button(s, crate::extras::Item::DiscordPresence)),
|
||||
),
|
||||
Row::Buttons(
|
||||
open("Extras...", Page::Extras),
|
||||
Some(open("Mods...", Page::Mods)),
|
||||
|
||||
@@ -61,6 +61,25 @@ pub enum ProjectileKind {
|
||||
color: u32,
|
||||
},
|
||||
}
|
||||
impl ProjectileKind {
|
||||
/// Its entity type.
|
||||
pub fn id(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Arrow { .. } => "minecraft:arrow",
|
||||
Self::Snowball => "minecraft:snowball",
|
||||
Self::Egg => "minecraft:egg",
|
||||
Self::EnderPearl => "minecraft:ender_pearl",
|
||||
Self::SmallFireball => "minecraft:small_fireball",
|
||||
Self::LargeFireball { .. } => "minecraft:fireball",
|
||||
Self::LlamaSpit => "minecraft:llama_spit",
|
||||
Self::WindCharge => "minecraft:wind_charge",
|
||||
Self::BreezeWindCharge => "minecraft:breeze_wind_charge",
|
||||
Self::ShulkerBullet { .. } => "minecraft:shulker_bullet",
|
||||
Self::WitherSkull { .. } => "minecraft:wither_skull",
|
||||
Self::Potion { .. } => "minecraft:splash_potion",
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Projectile {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
@@ -83,6 +102,8 @@ pub struct Projectile {
|
||||
pub bonus: crate::mobs::gear::ArrowBonus,
|
||||
/// Arrows' `AbstractArrow` state (ticked by `arrows::tick`).
|
||||
pub arrow: Option<Box<crate::arrows::ArrowState>>,
|
||||
/// `AbstractHurtingProjectile.accelerationPower`.
|
||||
pub acceleration: f64,
|
||||
}
|
||||
/// What a projectile struck this tick.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -146,6 +167,7 @@ impl Projectile {
|
||||
stuck: false,
|
||||
left_owner: false,
|
||||
bonus: Default::default(),
|
||||
acceleration: 0.1,
|
||||
arrow: kind_is_arrow.then(|| {
|
||||
// Entity.random: seeded unpredictably, the position stands in.
|
||||
Box::new(crate::arrows::ArrowState::new(
|
||||
@@ -571,9 +593,9 @@ impl Projectile {
|
||||
let v = self.velocity;
|
||||
let len = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt().max(1.0e-9);
|
||||
self.velocity = Vec3::new(
|
||||
(v.x + v.x / len * 0.1) * inertia,
|
||||
(v.y + v.y / len * 0.1) * inertia,
|
||||
(v.z + v.z / len * 0.1) * inertia,
|
||||
(v.x + v.x / len * self.acceleration) * inertia,
|
||||
(v.y + v.y / len * self.acceleration) * inertia,
|
||||
(v.z + v.z / len * self.acceleration) * inertia,
|
||||
);
|
||||
}
|
||||
// Arrows tick with `arrows::tick`.
|
||||
|
||||
@@ -581,23 +581,57 @@ impl Title {
|
||||
let cx = (w / 2.0).floor();
|
||||
let y = (h / 4.0).floor() + 48.0;
|
||||
let mut buttons = vec![
|
||||
b(cx - 100.0, y, 200.0, "Singleplayer", true, 1),
|
||||
b(cx - 100.0, y + 24.0, 200.0, "Multiplayer", true, 4),
|
||||
b(cx - 100.0, y + 48.0, 200.0, "Minecraft Realms", false, 0),
|
||||
b(cx - 100.0, y + 96.0, 98.0, "Options...", true, 3),
|
||||
b(cx + 2.0, y + 96.0, 98.0, "Quit Game", true, 2),
|
||||
];
|
||||
for (i, (label, enabled, id, icon, size)) in [
|
||||
// Horizon's Join a Friend (an invite code) behind
|
||||
// vanilla's Friends button.
|
||||
("Friends", true, 5, "friends/friends", 15.0),
|
||||
("Language", true, 6, "icon/language", 15.0),
|
||||
(
|
||||
"Accessibility Settings",
|
||||
b(
|
||||
cx - 100.0,
|
||||
y,
|
||||
200.0,
|
||||
&lang::get_or("menu.singleplayer", "Singleplayer"),
|
||||
true,
|
||||
1,
|
||||
),
|
||||
b(
|
||||
cx - 100.0,
|
||||
y + 24.0,
|
||||
200.0,
|
||||
&lang::get_or("menu.multiplayer", "Multiplayer"),
|
||||
true,
|
||||
4,
|
||||
),
|
||||
b(
|
||||
cx - 100.0,
|
||||
y + 48.0,
|
||||
200.0,
|
||||
&lang::get_or("menu.online", "Minecraft Realms"),
|
||||
false,
|
||||
0,
|
||||
),
|
||||
b(
|
||||
cx - 100.0,
|
||||
y + 96.0,
|
||||
98.0,
|
||||
&lang::get_or("menu.options", "Options..."),
|
||||
true,
|
||||
3,
|
||||
),
|
||||
b(
|
||||
cx + 2.0,
|
||||
y + 96.0,
|
||||
98.0,
|
||||
&lang::get_or("menu.quit", "Quit Game"),
|
||||
true,
|
||||
2,
|
||||
),
|
||||
];
|
||||
// CommonButtons' icon buttons; Horizon's Join a Friend (an
|
||||
// invite code) sits behind vanilla's Friends button.
|
||||
for (i, (key, plain, id, icon)) in [
|
||||
("gui.friends.open", "Friends", 5, "friends/friends"),
|
||||
("options.language", "Language...", 6, "icon/language"),
|
||||
(
|
||||
"options.accessibility",
|
||||
"Accessibility Settings...",
|
||||
7,
|
||||
"icon/accessibility",
|
||||
15.0,
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -607,11 +641,11 @@ impl Title {
|
||||
cx - 34.0 + i as f32 * 24.0,
|
||||
y + 72.0,
|
||||
20.0,
|
||||
label,
|
||||
enabled,
|
||||
&lang::get_or(key, plain),
|
||||
true,
|
||||
id,
|
||||
);
|
||||
button.icon = Some((icon, size));
|
||||
button.icon = Some((icon, 15.0));
|
||||
buttons.push(button);
|
||||
}
|
||||
buttons
|
||||
@@ -621,7 +655,7 @@ impl Title {
|
||||
(w / 2.0).floor() - 154.0,
|
||||
h - 52.0,
|
||||
150.0,
|
||||
"Play Selected World",
|
||||
&lang::get_or("selectWorld.select", "Play Selected World"),
|
||||
selected.is_some(),
|
||||
10,
|
||||
),
|
||||
@@ -629,7 +663,7 @@ impl Title {
|
||||
(w / 2.0).floor() + 4.0,
|
||||
h - 52.0,
|
||||
150.0,
|
||||
"Create New World",
|
||||
&lang::get_or("selectWorld.create", "Create New World"),
|
||||
true,
|
||||
11,
|
||||
),
|
||||
@@ -657,7 +691,14 @@ impl Title {
|
||||
selected.is_some(),
|
||||
15,
|
||||
),
|
||||
b((w / 2.0).floor() + 83.0, h - 28.0, 71.0, "Back", true, 12),
|
||||
b(
|
||||
(w / 2.0).floor() + 83.0,
|
||||
h - 28.0,
|
||||
71.0,
|
||||
&lang::get_or("gui.back", "Back"),
|
||||
true,
|
||||
12,
|
||||
),
|
||||
],
|
||||
Mode::Create {
|
||||
creative, options, ..
|
||||
|
||||
@@ -278,6 +278,65 @@ fn run<T: Send + 'static>(
|
||||
handle.join().unwrap().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprint_commands_survive_keyboard_input_changes() {
|
||||
let Some(resources) = resources() else {
|
||||
return;
|
||||
};
|
||||
let mut s = server(&resources);
|
||||
let port = s.hub.lan.port;
|
||||
let _bot = run(&mut s, move || connect(port, "Sprinter", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while s.hub.guests.is_empty() {
|
||||
s.tick(&[]);
|
||||
assert!(Instant::now() < deadline, "guest did not join");
|
||||
thread::yield_now();
|
||||
}
|
||||
let id = s.hub.guests[0].entity_id;
|
||||
s.hub.guests[0].loaded = true;
|
||||
s.hub.guests[0]
|
||||
.inbox
|
||||
.push_back(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 1,
|
||||
data: 0,
|
||||
}));
|
||||
s.tick(&[]);
|
||||
assert!(s.hub.guests[0].sprinting);
|
||||
// LocalPlayer keeps sprinting after Ctrl is released while W remains
|
||||
// down. Double-tap sprint also sends forward without the sprint key.
|
||||
for flags in [65, 1, 0, 33] {
|
||||
s.hub.guests[0]
|
||||
.inbox
|
||||
.push_back(S::PlayerInput(play::PlayerInput { flags }));
|
||||
s.tick(&[]);
|
||||
let g = &s.hub.guests[0];
|
||||
assert!(
|
||||
g.sprinting,
|
||||
"keyboard input {flags} cleared the sprint flag"
|
||||
);
|
||||
assert_eq!(g.input, flags);
|
||||
assert_eq!(g.sneaking, flags & 32 != 0);
|
||||
}
|
||||
s.hub.guests[0]
|
||||
.inbox
|
||||
.push_back(S::PlayerCommand(play::PlayerCommand {
|
||||
id,
|
||||
action: 2,
|
||||
data: 0,
|
||||
}));
|
||||
s.tick(&[]);
|
||||
assert!(!s.hub.guests[0].sprinting);
|
||||
s.hub.guests[0]
|
||||
.inbox
|
||||
.push_back(S::PlayerInput(play::PlayerInput { flags: 65 }));
|
||||
s.tick(&[]);
|
||||
assert!(
|
||||
!s.hub.guests[0].sprinting,
|
||||
"keyboard input started a sprint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_and_join() {
|
||||
let Some(resources) = resources() else {
|
||||
|
||||
@@ -40,5 +40,8 @@ REGISTRY_PUBLIC_URL=http://localhost:4173 REGISTRY_SESSION_KEY=... GITHUB_CLIENT
|
||||
| `REGISTRY_REPO` | Default `ludovic111/horizon-mods` |
|
||||
| `PORT` | Default 8080 |
|
||||
|
||||
On Railway it is its own service (`railway.json`, built with the `Dockerfile` from the repository root); the website's
|
||||
Caddy sends `/api/mods/*` to it over Railway's private network.
|
||||
On Railway it is the service `horizon-mod-registry` of the project `horizon-site`, next to the website: built from
|
||||
`main` with this `Dockerfile` from the repository root, health check `/healthz`, no public domain and no volume. The
|
||||
website's Caddy sends `/api/mods/*` to it over Railway's private network (`REGISTRY_UPSTREAM` on the website service:
|
||||
`http://horizon-mod-registry.railway.internal:8080`). `railway.json` lists the same settings; Railway reads them from
|
||||
the service, not from the file.
|
||||
@@ -625,11 +625,16 @@ pub fn publish(
|
||||
.map(|r| format!("- needs {} {}", r.id, r.version))
|
||||
.collect();
|
||||
let body = format!(
|
||||
"<!-- submitted-by: {login} -->\nSubmitted by @{login} through [the form]({public}/mods/submit).\n\n\
|
||||
"<!-- submitted-by: {login} -->\n🤖 Submitted through [{site}]({public}/mods/submit) by @{login}. The form opens the pull request with \
|
||||
the registry's token: neither Ludovic nor a reviewer wrote it.\n\n\
|
||||
| | |\n|---|---|\n| Project | `{id}` ({kind}, {new}) |\n| Version | {version} for {game} |\n| File | `{file}`, {size} bytes, SHA-256 `{sha256}` |\n| Signed | {signed} |\n| Source | {source} |\n\n{lists}\n\n\
|
||||
The checks below run the file's own checks again and, for a mod, a trial run in the sandbox. \
|
||||
Reviewers: read the source and the checks' report before you approve.",
|
||||
public = config.public_url,
|
||||
site = config
|
||||
.public_url
|
||||
.split_once("://")
|
||||
.map_or(config.public_url.as_str(), |(_, host)| host),
|
||||
id = form.id,
|
||||
kind = kind.name(),
|
||||
new = if is_new { "new" } else { "update" },
|
||||
|
||||
@@ -303,7 +303,11 @@ async fn a_submission_becomes_a_pull_request() {
|
||||
assert!(message.contains("Co-authored-by: alice <[email protected]>"));
|
||||
assert_eq!(replaced, &None);
|
||||
let body = m.pulls[0]["body"].as_str().unwrap();
|
||||
assert!(body.starts_with("<!-- submitted-by: alice -->"), "{body}");
|
||||
assert!(
|
||||
body.starts_with("<!-- submitted-by: alice -->\n🤖 Submitted through "),
|
||||
"{body}"
|
||||
);
|
||||
assert!(body.contains("/mods/submit) by @alice."), "{body}");
|
||||
assert!(body.contains("may draw text on your screen"), "{body}");
|
||||
}
|
||||
// The same version again: its branch is there.
|
||||
|
||||
@@ -13,8 +13,9 @@ use crate::{
|
||||
packets::{
|
||||
Empty,
|
||||
configuration::{
|
||||
ClientInformation, CustomPayload, Disconnect, KeepAlive, Ping, ResourcePackPop,
|
||||
ResourcePackPush, ResourcePackResponse, UpdateTags,
|
||||
ClientInformation, CookieRequest, CookieResponse, CustomPayload, Disconnect, KeepAlive,
|
||||
Ping, ResourcePackPop, ResourcePackPush, ResourcePackResponse, StoreCookie, Transfer,
|
||||
UpdateTags,
|
||||
},
|
||||
login::Property,
|
||||
},
|
||||
@@ -1759,6 +1760,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
ContainerSetContent(ContainerSetContent) = CONTAINER_SET_CONTENT,
|
||||
ContainerSetData(ContainerSetData) = CONTAINER_SET_DATA,
|
||||
ContainerSetSlot(ContainerSetSlot) = CONTAINER_SET_SLOT,
|
||||
CookieRequest(CookieRequest) = COOKIE_REQUEST,
|
||||
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
|
||||
Disconnect(Disconnect) = DISCONNECT,
|
||||
DisguisedChat(DisguisedChat) = DISGUISED_CHAT,
|
||||
@@ -1836,6 +1838,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
SoundEntity(SoundEntity) = SOUND_ENTITY,
|
||||
StopSound(StopSound) = STOP_SOUND,
|
||||
StartConfiguration(Empty) = START_CONFIGURATION,
|
||||
StoreCookie(StoreCookie) = STORE_COOKIE,
|
||||
SwingAnimation(SwingAnimation) = SWING_ANIMATION,
|
||||
SystemChat(SystemChat) = SYSTEM_CHAT,
|
||||
TabList(TabList) = TAB_LIST,
|
||||
@@ -1843,6 +1846,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
|
||||
TeleportEntity(TeleportEntity) = TELEPORT_ENTITY,
|
||||
TickingState(TickingState) = TICKING_STATE,
|
||||
TickingStep(TickingStep) = TICKING_STEP,
|
||||
Transfer(Transfer) = TRANSFER,
|
||||
UpdateAdvancements(UpdateAdvancements) = UPDATE_ADVANCEMENTS,
|
||||
UpdateAttributes(UpdateAttributes) = UPDATE_ATTRIBUTES,
|
||||
LevelParticles(LevelParticles) = LEVEL_PARTICLES,
|
||||
@@ -2223,6 +2227,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
|
||||
ContainerButtonClick(ContainerButtonClick) = CONTAINER_BUTTON_CLICK,
|
||||
ContainerClick(ContainerClick) = CONTAINER_CLICK,
|
||||
ContainerClose(ContainerClose) = CONTAINER_CLOSE,
|
||||
CookieResponse(CookieResponse) = COOKIE_RESPONSE,
|
||||
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
|
||||
EditBook(EditBook) = EDIT_BOOK,
|
||||
Interact(Interact) = INTERACT,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "horizon-shader-api"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.85"
|
||||
description = "Horizon's own shader packs (WGSL, docs/shaders.md): the format, the stages, the check, and the horizon-shader tool"
|
||||
|
||||
[dependencies]
|
||||
bytemuck = { version = "1.24", features = ["derive"] }
|
||||
# Parses and validates a pack's WGSL, and translates it as wgpu does for Vulkan, Metal and DirectX 12.
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "spv-out", "msl-out", "hlsl-out"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
toml = "1.1.6"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,4 @@
|
||||
# Golden Hour
|
||||
|
||||
Horizon's example shader pack (docs/shaders.md): a sky stage and two post passes, with five options. Check it with
|
||||
`horizon-shader check crates/horizon-shader-api/packs/golden_hour`.
|
||||
@@ -0,0 +1,13 @@
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
|
||||
// 1 while the sun is up in the Overworld, fading out around the horizon.
|
||||
fn daylight() -> f32 {
|
||||
return select(0.0, smoothstep(-0.1, 0.1, frame.sun_direction.y), frame.dimension == 0);
|
||||
}
|
||||
|
||||
// 1 when the sun is on the horizon, 0 from a quarter of the way up.
|
||||
fn sun_low() -> f32 {
|
||||
return 1.0 - smoothstep(0.0, 0.35, abs(frame.sun_direction.y));
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
schema = 1
|
||||
id = "golden_hour"
|
||||
name = "Golden Hour"
|
||||
version = "1.0.0"
|
||||
api = "0.1"
|
||||
description = "Warm light at sunrise and sunset, a soft glow around bright things and a gentle colour grade."
|
||||
authors = ["Horizon"]
|
||||
license = "MIT"
|
||||
shared = ["common.wgsl"]
|
||||
|
||||
[sky]
|
||||
file = "sky.wgsl"
|
||||
name = "Warm horizon"
|
||||
|
||||
[[post]]
|
||||
file = "post/glow.wgsl"
|
||||
name = "Glow"
|
||||
|
||||
[[post]]
|
||||
file = "post/grade.wgsl"
|
||||
name = "Colour grade"
|
||||
|
||||
[[options]]
|
||||
id = "sun_glow"
|
||||
name = "Sunset glow"
|
||||
description = "How warm the sky is around a low sun."
|
||||
type = "float"
|
||||
default = 0.6
|
||||
min = 0.0
|
||||
max = 1.0
|
||||
step = 0.05
|
||||
|
||||
[[options]]
|
||||
id = "glow"
|
||||
name = "Glow"
|
||||
description = "The soft halo around bright things: the sun, lava, torches."
|
||||
type = "float"
|
||||
default = 0.35
|
||||
min = 0.0
|
||||
max = 1.0
|
||||
step = 0.05
|
||||
|
||||
[[options]]
|
||||
id = "warmth"
|
||||
name = "Warmth"
|
||||
description = "How golden the light turns when the sun is low."
|
||||
type = "float"
|
||||
default = 0.4
|
||||
min = 0.0
|
||||
max = 1.0
|
||||
step = 0.05
|
||||
|
||||
[[options]]
|
||||
id = "saturation"
|
||||
name = "Saturation"
|
||||
type = "float"
|
||||
default = 1.1
|
||||
min = 0.0
|
||||
max = 2.0
|
||||
step = 0.05
|
||||
|
||||
[[options]]
|
||||
id = "vignette"
|
||||
name = "Vignette"
|
||||
type = "bool"
|
||||
default = true
|
||||
|
||||
[budget]
|
||||
gpu_ms = 1.0
|
||||
@@ -0,0 +1,22 @@
|
||||
// A soft halo around bright pixels: twelve taps on two rings, scaled with
|
||||
// the picture's height so it looks the same at any resolution.
|
||||
override glow: f32 = 0.35;
|
||||
|
||||
fn bright(c: vec3<f32>) -> vec3<f32> {
|
||||
return c * smoothstep(0.7, 1.0, luminance(c));
|
||||
}
|
||||
|
||||
fn post(input: PostInput) -> vec4<f32> {
|
||||
if glow <= 0.0 {
|
||||
return input.color;
|
||||
}
|
||||
let step = frame.resolution.y / 720.0 / frame.resolution;
|
||||
var sum = vec3<f32>(0.0);
|
||||
for (var i = 0; i < 12; i++) {
|
||||
let angle = f32(i) * 0.5235988;
|
||||
let radius = select(3.0, 7.0, (i & 1) == 1);
|
||||
let offset = vec2<f32>(cos(angle), sin(angle)) * radius * step;
|
||||
sum += bright(textureSampleLevel(previous, linear_sampler, input.uv + offset, 0.0).rgb);
|
||||
}
|
||||
return vec4<f32>(input.color.rgb + sum * (glow / 6.0), input.color.a);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Golden light when the sun is low, a soft shoulder for the brightest
|
||||
// colours (white stays white), saturation and a light vignette.
|
||||
override warmth: f32 = 0.4;
|
||||
override saturation: f32 = 1.1;
|
||||
override vignette: bool = true;
|
||||
|
||||
fn post(input: PostInput) -> vec4<f32> {
|
||||
var c = input.color.rgb;
|
||||
let golden = warmth * mix(0.3, 1.0, sun_low()) * daylight() * (1.0 - frame.rain * 0.7);
|
||||
c *= mix(vec3<f32>(1.0), vec3<f32>(1.08, 1.0, 0.86), golden);
|
||||
c = c / (1.0 + 0.25 * c) * 1.25;
|
||||
c = mix(vec3<f32>(luminance(c)), c, saturation);
|
||||
if vignette {
|
||||
let aspect = frame.resolution.x / frame.resolution.y;
|
||||
let d = length((input.uv - 0.5) * vec2<f32>(aspect, 1.0)) / length(vec2<f32>(aspect, 1.0) * 0.5);
|
||||
c *= 1.0 - smoothstep(0.55, 1.1, d) * 0.3;
|
||||
}
|
||||
return vec4<f32>(clamp(c, vec3<f32>(0.0), vec3<f32>(1.0)), input.color.a);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Warms the sky around a low sun, and deepens the blue overhead by day.
|
||||
override sun_glow: f32 = 0.6;
|
||||
|
||||
fn sky(input: SkyInput) -> vec4<f32> {
|
||||
let d = input.direction;
|
||||
let toward = max(dot(d, frame.sun_direction), 0.0);
|
||||
let near_horizon = 1.0 - smoothstep(0.0, 0.4, abs(d.y));
|
||||
let day = daylight() * (1.0 - frame.rain);
|
||||
let halo = pow(toward, 6.0) * 0.6 + near_horizon * toward * toward * 0.4;
|
||||
let warm = vec3<f32>(1.0, 0.55, 0.25) * halo * sun_low() * day * sun_glow;
|
||||
let zenith = smoothstep(0.1, 1.0, d.y) * day * 0.12;
|
||||
let base = input.color.rgb * (1.0 - zenith * vec3<f32>(0.5, 0.25, 0.0));
|
||||
return vec4<f32>(base + warm, input.color.a);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! `horizon-shader`: make Horizon shader packs (docs/shaders.md).
|
||||
//!
|
||||
//! - `new <dir> [--template post|sky|material] [--name "Name"]`: a pack from a template.
|
||||
//! - `check <pack>... [--json]`: the check the game, the launcher and the registry run.
|
||||
//! - `pack <dir> [--out <file.hshader>]`: check, then write the reproducible `.hshader`.
|
||||
use horizon_shader_api::{EXTENSION, MANIFEST, Pack, Problem, check, package::sha256_hex};
|
||||
use serde::Serialize;
|
||||
use std::{path::Path, process::ExitCode};
|
||||
|
||||
const USAGE: &str = "usage: horizon-shader new <dir> [--template post|sky|material] [--name \"Name\"] | check <pack>... [--json] | pack <dir> [--out <file.hshader>]";
|
||||
|
||||
/// (template, its files as (name, text)).
|
||||
const TEMPLATES: [(&str, [(&str, &str); 2]); 3] = [
|
||||
(
|
||||
"post",
|
||||
[
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../../templates/post/horizon-shaders.toml"),
|
||||
),
|
||||
("post.wgsl", include_str!("../../templates/post/post.wgsl")),
|
||||
],
|
||||
),
|
||||
(
|
||||
"sky",
|
||||
[
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../../templates/sky/horizon-shaders.toml"),
|
||||
),
|
||||
("sky.wgsl", include_str!("../../templates/sky/sky.wgsl")),
|
||||
],
|
||||
),
|
||||
(
|
||||
"material",
|
||||
[
|
||||
(
|
||||
MANIFEST,
|
||||
include_str!("../../templates/material/horizon-shaders.toml"),
|
||||
),
|
||||
(
|
||||
"material.wgsl",
|
||||
include_str!("../../templates/material/material.wgsl"),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => new(&args[1..]),
|
||||
Some("check") => check_command(&args[1..]),
|
||||
Some("pack") => pack(&args[1..]),
|
||||
_ => Err(USAGE.into()),
|
||||
};
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The value after `flag` in `args`.
|
||||
fn flag<'a>(args: &'a [String], flag: &str) -> Option<&'a str> {
|
||||
let i = args.iter().position(|a| a == flag)?;
|
||||
args.get(i + 1).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The arguments that are neither flags nor their values.
|
||||
fn positional(args: &[String], flags: &[&str]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if flags.contains(&a.as_str()) {
|
||||
it.next();
|
||||
} else if !a.starts_with("--") {
|
||||
out.push(a.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn new(args: &[String]) -> Result<(), String> {
|
||||
let [dir] = positional(args, &["--template", "--name"])
|
||||
.try_into()
|
||||
.map_err(|_| USAGE.to_string())?;
|
||||
let dir = Path::new(&dir);
|
||||
if dir.exists() {
|
||||
return Err(format!("{} already exists", dir.display()));
|
||||
}
|
||||
let template = flag(args, "--template").unwrap_or("post");
|
||||
let (_, files) = TEMPLATES
|
||||
.iter()
|
||||
.find(|(name, _)| *name == template)
|
||||
.ok_or_else(|| format!("no template {template:?}: post, sky or material"))?;
|
||||
let base = dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or("the path needs a folder name")?;
|
||||
let mut id: String = base
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'a'..='z' | '0'..='9' => c,
|
||||
'A'..='Z' => c.to_ascii_lowercase(),
|
||||
_ => '_',
|
||||
})
|
||||
.collect();
|
||||
id.truncate(64);
|
||||
let name = flag(args, "--name").map_or_else(|| base.replace(['-', '_'], " "), str::to_owned);
|
||||
std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for (file, text) in files {
|
||||
let text = text
|
||||
.replace("{{id}}", &id)
|
||||
.replace("{{name}}", &name.replace('"', "'"));
|
||||
let path = dir.join(file);
|
||||
std::fs::write(&path, text).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
}
|
||||
println!(
|
||||
"Made {} from the {template} template. Edit it, then: horizon-shader check {}",
|
||||
dir.display(),
|
||||
dir.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct StageJson {
|
||||
stage: &'static str,
|
||||
file: String,
|
||||
label: String,
|
||||
expressions: usize,
|
||||
texture_reads: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProblemJson {
|
||||
file: String,
|
||||
line: usize,
|
||||
column: usize,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CheckJson {
|
||||
path: String,
|
||||
ok: bool,
|
||||
id: Option<String>,
|
||||
version: Option<String>,
|
||||
api: Option<String>,
|
||||
/// The file's own SHA-256 (a `.hshader`), else the reproducible package's.
|
||||
sha256: Option<String>,
|
||||
stages: Vec<StageJson>,
|
||||
problems: Vec<ProblemJson>,
|
||||
}
|
||||
|
||||
/// Reads and checks the pack at `path`.
|
||||
fn check_one(path: &Path) -> CheckJson {
|
||||
let mut out = CheckJson {
|
||||
path: path.display().to_string(),
|
||||
ok: false,
|
||||
id: None,
|
||||
version: None,
|
||||
api: None,
|
||||
sha256: None,
|
||||
stages: Vec::new(),
|
||||
problems: Vec::new(),
|
||||
};
|
||||
let problem = |message: String| ProblemJson {
|
||||
file: path.display().to_string(),
|
||||
line: 0,
|
||||
column: 0,
|
||||
message,
|
||||
};
|
||||
let pack = match Pack::read(path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
out.problems.push(problem(e));
|
||||
return out;
|
||||
}
|
||||
};
|
||||
out.id = Some(pack.manifest.id.clone());
|
||||
out.version = Some(pack.manifest.version.clone());
|
||||
out.api = Some(pack.manifest.api.clone());
|
||||
out.sha256 = if path.is_dir() {
|
||||
pack.sha256().ok()
|
||||
} else {
|
||||
std::fs::read(path).ok().map(|b| sha256_hex(&b))
|
||||
};
|
||||
match check(&pack) {
|
||||
Ok(report) => {
|
||||
out.ok = true;
|
||||
out.stages = report
|
||||
.stages
|
||||
.into_iter()
|
||||
.map(|s| StageJson {
|
||||
stage: s.kind.name(),
|
||||
file: s.file,
|
||||
label: s.label,
|
||||
expressions: s.expressions,
|
||||
texture_reads: s.texture_reads,
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Err(problems) => {
|
||||
out.problems = problems
|
||||
.into_iter()
|
||||
.map(
|
||||
|Problem {
|
||||
file,
|
||||
line,
|
||||
column,
|
||||
message,
|
||||
}| ProblemJson {
|
||||
file,
|
||||
line,
|
||||
column,
|
||||
message,
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check_command(args: &[String]) -> Result<(), String> {
|
||||
let paths = positional(args, &[]);
|
||||
if paths.is_empty() {
|
||||
return Err(USAGE.into());
|
||||
}
|
||||
let results: Vec<CheckJson> = paths.iter().map(|p| check_one(Path::new(p))).collect();
|
||||
let failed = results.iter().filter(|r| !r.ok).count();
|
||||
if args.iter().any(|a| a == "--json") {
|
||||
let json = serde_json::to_string_pretty(&results).map_err(|e| e.to_string())?;
|
||||
println!("{json}");
|
||||
} else {
|
||||
for r in &results {
|
||||
print_result(r);
|
||||
}
|
||||
}
|
||||
match failed {
|
||||
0 => Ok(()),
|
||||
n => Err(format!("{n} of {} packs failed the check", results.len())),
|
||||
}
|
||||
}
|
||||
|
||||
fn print_result(r: &CheckJson) {
|
||||
let what = match (&r.id, &r.version, &r.api) {
|
||||
(Some(id), Some(v), Some(api)) => format!("{id} {v} (shader API {api})"),
|
||||
_ => r.path.clone(),
|
||||
};
|
||||
if !r.ok {
|
||||
println!("{what}: FAILED");
|
||||
for p in &r.problems {
|
||||
if p.line == 0 {
|
||||
println!(" {}: {}", p.file, p.message);
|
||||
} else {
|
||||
println!(" {}:{}:{}: {}", p.file, p.line, p.column, p.message);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
println!("{what}: ok");
|
||||
for s in &r.stages {
|
||||
println!(
|
||||
" {:<9} {:<28} {:>6} expressions, {} texture reads",
|
||||
s.stage, s.label, s.expressions, s.texture_reads
|
||||
);
|
||||
}
|
||||
if let Some(sha) = &r.sha256 {
|
||||
println!(" sha256 {sha}");
|
||||
}
|
||||
}
|
||||
|
||||
fn pack(args: &[String]) -> Result<(), String> {
|
||||
let [dir] = positional(args, &["--out"])
|
||||
.try_into()
|
||||
.map_err(|_| USAGE.to_string())?;
|
||||
let dir = Path::new(&dir)
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("{dir}: {e}"))?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{} is not a pack folder", dir.display()));
|
||||
}
|
||||
let dir = dir.as_path();
|
||||
let pack = Pack::read(dir)?;
|
||||
if let Err(problems) = check(&pack) {
|
||||
for p in &problems {
|
||||
eprintln!("{p}");
|
||||
}
|
||||
return Err(format!("{} failed the check", dir.display()));
|
||||
}
|
||||
let bytes = pack.package()?;
|
||||
let out = flag(args, "--out").map_or_else(
|
||||
|| {
|
||||
let m = &pack.manifest;
|
||||
dir.with_file_name(format!("{}-{}.{EXTENSION}", m.id, m.version))
|
||||
},
|
||||
Into::into,
|
||||
);
|
||||
std::fs::write(&out, &bytes).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
println!("Wrote {} (sha256 {})", out.display(), sha256_hex(&bytes));
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
//! The check a pack passes before it runs: in the game when it loads (a pack
|
||||
//! that fails keeps the last good one drawing), in the launcher before it
|
||||
//! installs, in the registry's CI, and in `horizon-shader check`.
|
||||
//!
|
||||
//! Each stage is built as the renderer builds it, parsed and validated by
|
||||
//! naga, checked against its signature and the API's rules (no bindings, no
|
||||
//! entry points, no `horizon_` names), then translated to SPIR-V, Metal and
|
||||
//! HLSL with its options at their defaults, minimums and maximums.
|
||||
use crate::manifest::{OptionKind, PackOption};
|
||||
use crate::package::Pack;
|
||||
use crate::stages::{Composed, StageKind, compose};
|
||||
use naga::valid::{Capabilities, ModuleInfo, ValidationFlags, Validator};
|
||||
use std::collections::HashSet;
|
||||
use std::fmt;
|
||||
|
||||
/// naga expressions a stage may hold, over all its functions.
|
||||
pub const MAX_EXPRESSIONS: usize = 20_000;
|
||||
/// Texture reads (samples, gathers and loads) a stage may write.
|
||||
pub const MAX_TEXTURE_READS: usize = 64;
|
||||
|
||||
/// What is wrong, and where: a file of the pack, with the line and column
|
||||
/// when known (0 when not).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Problem {
|
||||
pub file: String,
|
||||
pub line: usize,
|
||||
pub column: usize,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for Problem {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
if self.line == 0 {
|
||||
write!(f, "{}: {}", self.file, self.message)
|
||||
} else {
|
||||
write!(
|
||||
f,
|
||||
"{}:{}:{}: {}",
|
||||
self.file, self.line, self.column, self.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn problem(file: &str, message: impl Into<String>) -> Problem {
|
||||
Problem {
|
||||
file: file.to_owned(),
|
||||
line: 0,
|
||||
column: 0,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// One stage that passed, ready for the renderer.
|
||||
#[derive(Debug)]
|
||||
pub struct CheckedStage {
|
||||
pub kind: StageKind,
|
||||
pub file: String,
|
||||
/// The manifest's name for it, else its file.
|
||||
pub label: String,
|
||||
pub module: naga::Module,
|
||||
pub info: ModuleInfo,
|
||||
pub expressions: usize,
|
||||
pub texture_reads: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Report {
|
||||
pub stages: Vec<CheckedStage>,
|
||||
}
|
||||
|
||||
/// Checks every stage of `pack`, and that each option is an `override` of
|
||||
/// its type in some stage. Returns every problem found, not just the first.
|
||||
pub fn check(pack: &Pack) -> Result<Report, Vec<Problem>> {
|
||||
let m = &pack.manifest;
|
||||
let stages = m
|
||||
.sky
|
||||
.iter()
|
||||
.map(|s| (StageKind::Sky, s))
|
||||
.chain(m.material.iter().map(|s| (StageKind::Material, s)))
|
||||
.chain(m.post.iter().map(|s| (StageKind::Post, s)));
|
||||
let (mut checked, mut problems) = (Vec::new(), Vec::new());
|
||||
for (kind, stage) in stages {
|
||||
match check_stage(pack, kind, &stage.file) {
|
||||
Ok(mut c) => {
|
||||
c.label = stage.name.clone().unwrap_or_else(|| stage.file.clone());
|
||||
checked.push(c);
|
||||
}
|
||||
Err(p) => problems.extend(p),
|
||||
}
|
||||
}
|
||||
if problems.is_empty() {
|
||||
for option in &m.options {
|
||||
let used = checked.iter().any(|c| {
|
||||
c.module
|
||||
.overrides
|
||||
.iter()
|
||||
.any(|(_, o)| o.name.as_deref() == Some(&option.id))
|
||||
});
|
||||
if !used {
|
||||
problems.push(problem(
|
||||
crate::MANIFEST,
|
||||
format!(
|
||||
"option {:?}: no stage declares `override {}: {}`",
|
||||
option.id,
|
||||
option.id,
|
||||
option.kind.wgsl_type()
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if problems.is_empty() {
|
||||
Ok(Report { stages: checked })
|
||||
} else {
|
||||
Err(problems)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds and checks one stage of `pack` from its file.
|
||||
pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result<CheckedStage, Vec<Problem>> {
|
||||
// First without the entry points, so a missing or mistyped stage function
|
||||
// is reported as such rather than as a call that doesn't resolve.
|
||||
let mut own = compose(pack, kind, file);
|
||||
let entries = own.parts.pop().expect("entries are the last part");
|
||||
own.text.truncate(entries.1.start);
|
||||
let module = parse(&own)?;
|
||||
let mut problems = signature(&module, kind, file);
|
||||
problems.extend(api_rules(&module, kind, &own));
|
||||
problems.extend(options(&module, &pack.manifest.options, file));
|
||||
if !problems.is_empty() {
|
||||
return Err(problems);
|
||||
}
|
||||
let composed = compose(pack, kind, file);
|
||||
let module = parse(&composed)?;
|
||||
let info = Validator::new(ValidationFlags::all(), Capabilities::default())
|
||||
.validate(&module)
|
||||
.map_err(|e| {
|
||||
let at = e.spans().next().and_then(|(span, _)| span.to_range());
|
||||
let mut message = e.as_inner().to_string();
|
||||
let mut source = std::error::Error::source(e.as_inner());
|
||||
while let Some(s) = source {
|
||||
message += &format!(": {s}");
|
||||
source = s.source();
|
||||
}
|
||||
vec![located(&composed, at.map(|r| r.start), message)]
|
||||
})?;
|
||||
let (expressions, texture_reads) = cost(&module);
|
||||
let mut problems = Vec::new();
|
||||
if expressions > MAX_EXPRESSIONS {
|
||||
problems.push(problem(
|
||||
file,
|
||||
format!("{expressions} expressions; a stage may hold {MAX_EXPRESSIONS}"),
|
||||
));
|
||||
}
|
||||
if texture_reads > MAX_TEXTURE_READS {
|
||||
problems.push(problem(
|
||||
file,
|
||||
format!("{texture_reads} texture reads; a stage may write {MAX_TEXTURE_READS}"),
|
||||
));
|
||||
}
|
||||
problems.extend(translate(&module, &info, &pack.manifest.options, file));
|
||||
if !problems.is_empty() {
|
||||
return Err(problems);
|
||||
}
|
||||
Ok(CheckedStage {
|
||||
kind,
|
||||
file: file.to_owned(),
|
||||
label: file.to_owned(),
|
||||
module,
|
||||
info,
|
||||
expressions,
|
||||
texture_reads,
|
||||
})
|
||||
}
|
||||
|
||||
fn located(composed: &Composed, offset: Option<usize>, message: String) -> Problem {
|
||||
match offset {
|
||||
Some(at) => {
|
||||
let (file, line, column) = composed.locate(at);
|
||||
Problem {
|
||||
file: file.to_owned(),
|
||||
line,
|
||||
column,
|
||||
message,
|
||||
}
|
||||
}
|
||||
None => problem(&composed.parts[composed.parts.len() - 2].0, message),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(composed: &Composed) -> Result<naga::Module, Vec<Problem>> {
|
||||
naga::front::wgsl::parse_str(&composed.text).map_err(|e| {
|
||||
let label = e.labels().next();
|
||||
let mut message = e.message().to_owned();
|
||||
if let Some((_, text)) = label.filter(|(_, t)| !t.is_empty() && !message.contains(*t)) {
|
||||
message += &format!(" ({text})");
|
||||
}
|
||||
let at = label.and_then(|(span, _)| span.to_range()).map(|r| r.start);
|
||||
vec![located(composed, at, message)]
|
||||
})
|
||||
}
|
||||
|
||||
fn type_name(module: &naga::Module, ty: naga::Handle<naga::Type>) -> String {
|
||||
let t = &module.types[ty];
|
||||
if let Some(name) = &t.name {
|
||||
return name.clone();
|
||||
}
|
||||
match t.inner {
|
||||
naga::TypeInner::Scalar(s) => scalar_name(s).to_owned(),
|
||||
naga::TypeInner::Vector { size, scalar } => {
|
||||
format!("vec{}<{}>", size as u8, scalar_name(scalar))
|
||||
}
|
||||
_ => format!("{:?}", t.inner),
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar_name(s: naga::Scalar) -> &'static str {
|
||||
match (s.kind, s.width) {
|
||||
(naga::ScalarKind::Bool, _) => "bool",
|
||||
(naga::ScalarKind::Float, 4) => "f32",
|
||||
(naga::ScalarKind::Sint, 4) => "i32",
|
||||
(naga::ScalarKind::Uint, 4) => "u32",
|
||||
_ => "another scalar",
|
||||
}
|
||||
}
|
||||
|
||||
fn signature(module: &naga::Module, kind: StageKind, file: &str) -> Vec<Problem> {
|
||||
let found = module
|
||||
.functions
|
||||
.iter()
|
||||
.find(|(_, f)| f.name.as_deref() == Some(kind.function()));
|
||||
let Some((_, f)) = found else {
|
||||
return vec![problem(file, format!("defines no `{}`", kind.signature()))];
|
||||
};
|
||||
let args: Vec<String> = f
|
||||
.arguments
|
||||
.iter()
|
||||
.map(|a| type_name(module, a.ty))
|
||||
.collect();
|
||||
let result = f.result.as_ref().map(|r| type_name(module, r.ty));
|
||||
if args == [kind.input().name] && result.as_deref() == Some(kind.output()) {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![problem(
|
||||
file,
|
||||
format!(
|
||||
"`fn {}({}) -> {}` should be `{}`",
|
||||
kind.function(),
|
||||
args.join(", "),
|
||||
result.as_deref().unwrap_or("()"),
|
||||
kind.signature()
|
||||
),
|
||||
)]
|
||||
}
|
||||
|
||||
/// The names of everything a module defines at its top level.
|
||||
fn names(module: &naga::Module) -> HashSet<String> {
|
||||
let types = module.types.iter().filter_map(|(_, t)| t.name.clone());
|
||||
let functions = module.functions.iter().filter_map(|(_, f)| f.name.clone());
|
||||
let globals = module
|
||||
.global_variables
|
||||
.iter()
|
||||
.filter_map(|(_, g)| g.name.clone());
|
||||
let constants = module.constants.iter().filter_map(|(_, c)| c.name.clone());
|
||||
let overrides = module.overrides.iter().filter_map(|(_, o)| o.name.clone());
|
||||
types
|
||||
.chain(functions)
|
||||
.chain(globals)
|
||||
.chain(constants)
|
||||
.chain(overrides)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pack's files declare no bindings, no entry points and no `horizon_`
|
||||
/// names: what the prelude doesn't define is the pack's.
|
||||
fn api_rules(module: &naga::Module, kind: StageKind, own: &Composed) -> Vec<Problem> {
|
||||
let prelude = naga::front::wgsl::parse_str(&kind.prelude()).expect("the prelude parses");
|
||||
let ours = names(&prelude);
|
||||
let file = &own.parts[own.parts.len() - 1].0;
|
||||
let mut problems = Vec::new();
|
||||
for e in &module.entry_points {
|
||||
problems.push(problem(
|
||||
file,
|
||||
format!(
|
||||
"declares the entry point `{}`: a stage is a function, Horizon adds the entry points",
|
||||
e.name
|
||||
),
|
||||
));
|
||||
}
|
||||
for (_, g) in module.global_variables.iter() {
|
||||
let name = g.name.clone().unwrap_or_default();
|
||||
if g.binding.is_some() && !ours.contains(&name) {
|
||||
problems.push(problem(
|
||||
file,
|
||||
format!(
|
||||
"declares the binding `{name}`: a stage reads only the resources of its prelude"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut theirs: Vec<String> = names(module)
|
||||
.into_iter()
|
||||
.filter(|n| !ours.contains(n) && n.starts_with("horizon_"))
|
||||
.collect();
|
||||
theirs.sort();
|
||||
for name in theirs {
|
||||
problems.push(problem(
|
||||
file,
|
||||
format!("`{name}`: names starting with horizon_ are Horizon's"),
|
||||
));
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
/// Each `override` is an option of its type, or has a default of its own.
|
||||
fn options(module: &naga::Module, options: &[PackOption], file: &str) -> Vec<Problem> {
|
||||
let mut problems = Vec::new();
|
||||
for (_, o) in module.overrides.iter() {
|
||||
let name = o.name.as_deref().unwrap_or("?");
|
||||
let ty = type_name(module, o.ty);
|
||||
match options.iter().find(|opt| opt.id == name) {
|
||||
Some(opt) if ty != opt.kind.wgsl_type() => problems.push(problem(
|
||||
file,
|
||||
format!(
|
||||
"`override {name}: {ty}`: option {name:?} is a {}, so its override is `{}`",
|
||||
kind_name(&opt.kind),
|
||||
opt.kind.wgsl_type()
|
||||
),
|
||||
)),
|
||||
None if o.init.is_none() => problems.push(problem(
|
||||
file,
|
||||
format!("`override {name}` has no default and no [[options]] entry"),
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
fn kind_name(kind: &OptionKind) -> &'static str {
|
||||
match kind {
|
||||
OptionKind::Bool { .. } => "bool",
|
||||
OptionKind::Float { .. } => "float",
|
||||
OptionKind::Int { .. } => "int",
|
||||
OptionKind::Choice { .. } => "choice",
|
||||
}
|
||||
}
|
||||
|
||||
fn cost(module: &naga::Module) -> (usize, usize) {
|
||||
let functions = module
|
||||
.functions
|
||||
.iter()
|
||||
.map(|(_, f)| f)
|
||||
.chain(module.entry_points.iter().map(|e| &e.function));
|
||||
let (mut expressions, mut reads) = (0, 0);
|
||||
for f in functions {
|
||||
expressions += f.expressions.len();
|
||||
reads += f
|
||||
.expressions
|
||||
.iter()
|
||||
.filter(|(_, e)| {
|
||||
matches!(
|
||||
e,
|
||||
naga::Expression::ImageSample { .. } | naga::Expression::ImageLoad { .. }
|
||||
)
|
||||
})
|
||||
.count();
|
||||
}
|
||||
(expressions, reads)
|
||||
}
|
||||
|
||||
/// The option values a pack is translated with: its defaults, then every
|
||||
/// option at its minimum, then at its maximum.
|
||||
fn option_sets(options: &[PackOption]) -> [(&'static str, Vec<(String, f64)>); 3] {
|
||||
let at = |pick: fn(&OptionKind) -> f64| {
|
||||
options
|
||||
.iter()
|
||||
.map(|o| (o.id.clone(), pick(&o.kind)))
|
||||
.collect()
|
||||
};
|
||||
[
|
||||
("defaults", at(OptionKind::default_constant)),
|
||||
(
|
||||
"minimums",
|
||||
at(|k| match *k {
|
||||
OptionKind::Bool { .. } | OptionKind::Choice { .. } => 0.0,
|
||||
OptionKind::Float { min, .. } => f64::from(min),
|
||||
OptionKind::Int { min, .. } => f64::from(min),
|
||||
}),
|
||||
),
|
||||
(
|
||||
"maximums",
|
||||
at(|k| match k {
|
||||
OptionKind::Bool { .. } => 1.0,
|
||||
OptionKind::Choice { values, .. } => (values.len() - 1) as f64,
|
||||
OptionKind::Float { max, .. } => f64::from(*max),
|
||||
OptionKind::Int { max, .. } => f64::from(*max),
|
||||
}),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Translates every entry point for Vulkan (SPIR-V), Metal and DirectX 12
|
||||
/// (HLSL) with each option set, as wgpu does when it builds the pipelines.
|
||||
fn translate(
|
||||
module: &naga::Module,
|
||||
info: &ModuleInfo,
|
||||
options: &[PackOption],
|
||||
file: &str,
|
||||
) -> Vec<Problem> {
|
||||
let present: HashSet<&str> = module
|
||||
.overrides
|
||||
.iter()
|
||||
.filter_map(|(_, o)| o.name.as_deref())
|
||||
.collect();
|
||||
let mut problems = Vec::new();
|
||||
for (set, values) in option_sets(options) {
|
||||
let mut constants = naga::back::PipelineConstants::default();
|
||||
for (id, value) in values
|
||||
.into_iter()
|
||||
.filter(|(id, _)| present.contains(id.as_str()))
|
||||
{
|
||||
constants.insert(id, value);
|
||||
}
|
||||
for e in &module.entry_points {
|
||||
let fail = |backend: &str, err: String| {
|
||||
problem(
|
||||
file,
|
||||
format!("with the options at their {set}, {backend} refuses it: {err}"),
|
||||
)
|
||||
};
|
||||
let processed = naga::back::pipeline_constants::process_overrides(
|
||||
module,
|
||||
info,
|
||||
Some((e.stage, &e.name)),
|
||||
&constants,
|
||||
);
|
||||
let (m, i) = match processed {
|
||||
Ok(p) => p,
|
||||
Err(err) => {
|
||||
problems.push(fail("the override step", err.to_string()));
|
||||
return problems;
|
||||
}
|
||||
};
|
||||
let spv = naga::back::spv::write_vec(
|
||||
&m,
|
||||
&i,
|
||||
&naga::back::spv::Options::default(),
|
||||
Some(&naga::back::spv::PipelineOptions {
|
||||
shader_stage: e.stage,
|
||||
entry_point: e.name.clone(),
|
||||
}),
|
||||
);
|
||||
let msl = naga::back::msl::write_string(
|
||||
&m,
|
||||
&i,
|
||||
&naga::back::msl::Options {
|
||||
lang_version: (2, 2),
|
||||
..Default::default()
|
||||
},
|
||||
&naga::back::msl::PipelineOptions {
|
||||
entry_point: Some((e.stage, e.name.clone())),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let mut hlsl = String::new();
|
||||
let hlsl_result = naga::back::hlsl::Writer::new(
|
||||
&mut hlsl,
|
||||
&naga::back::hlsl::Options::default(),
|
||||
&naga::back::hlsl::PipelineOptions {
|
||||
entry_point: Some((e.stage, e.name.clone())),
|
||||
},
|
||||
)
|
||||
.write(&m, &i, None);
|
||||
let errors = [
|
||||
("Vulkan (SPIR-V)", spv.err().map(|e| e.to_string())),
|
||||
("Metal", msl.err().map(|e| e.to_string())),
|
||||
(
|
||||
"DirectX 12 (HLSL)",
|
||||
hlsl_result.err().map(|e| e.to_string()),
|
||||
),
|
||||
];
|
||||
for (backend, err) in errors {
|
||||
if let Some(err) = err {
|
||||
problems.push(fail(backend, err));
|
||||
}
|
||||
}
|
||||
if !problems.is_empty() {
|
||||
return problems;
|
||||
}
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! What a pack's WGSL reads: the frame uniform every stage sees, which the
|
||||
//! renderer fills and uploads as the Rust struct below, and the stage input
|
||||
//! and output structs. The WGSL of each is generated from these definitions
|
||||
//! (`Struct::wgsl`), and so is the reference (docs/api/shaders-reference.md).
|
||||
|
||||
/// One field of a WGSL struct, for the prelude and the reference.
|
||||
#[derive(Debug)]
|
||||
pub struct Field {
|
||||
pub name: &'static str,
|
||||
pub ty: &'static str,
|
||||
/// Its meaning, unit and range.
|
||||
pub doc: &'static str,
|
||||
}
|
||||
|
||||
/// A struct a pack's WGSL can name.
|
||||
#[derive(Debug)]
|
||||
pub struct Struct {
|
||||
pub name: &'static str,
|
||||
pub doc: &'static str,
|
||||
pub fields: &'static [Field],
|
||||
}
|
||||
|
||||
impl Struct {
|
||||
pub fn wgsl(&self) -> String {
|
||||
let mut out = format!("struct {} {{\n", self.name);
|
||||
for f in self.fields {
|
||||
out += &format!(" {}: {},\n", f.name, f.ty);
|
||||
}
|
||||
out + "}\n"
|
||||
}
|
||||
}
|
||||
|
||||
/// The WGSL type of a Rust field the renderer uploads.
|
||||
pub trait Wgsl {
|
||||
const TYPE: &'static str;
|
||||
}
|
||||
|
||||
macro_rules! wgsl_types {
|
||||
($($rust:ty => $wgsl:literal),* $(,)?) => {
|
||||
$(impl Wgsl for $rust {
|
||||
const TYPE: &'static str = $wgsl;
|
||||
})*
|
||||
};
|
||||
}
|
||||
|
||||
wgsl_types! {
|
||||
f32 => "f32",
|
||||
u32 => "u32",
|
||||
i32 => "i32",
|
||||
[f32; 2] => "vec2<f32>",
|
||||
[f32; 3] => "vec3<f32>",
|
||||
[f32; 4] => "vec4<f32>",
|
||||
[[f32; 4]; 4] => "mat4x4<f32>",
|
||||
}
|
||||
|
||||
/// A uniform struct: the Rust type the renderer uploads, and its WGSL.
|
||||
macro_rules! uniform {
|
||||
($(#[doc = $doc:literal])* $name:ident {
|
||||
$($(#[doc = $fdoc:literal])* $field:ident: $ty:ty,)*
|
||||
}) => {
|
||||
$(#[doc = $doc])*
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct $name {
|
||||
$($(#[doc = $fdoc])* pub $field: $ty,)*
|
||||
}
|
||||
|
||||
impl $name {
|
||||
pub const WGSL: Struct = Struct {
|
||||
name: stringify!($name),
|
||||
doc: concat!($($doc, "\n"),*),
|
||||
fields: &[$(Field {
|
||||
name: stringify!($field),
|
||||
ty: <$ty as Wgsl>::TYPE,
|
||||
doc: concat!($($fdoc),*),
|
||||
}),*],
|
||||
};
|
||||
/// Each field's byte offset, in `WGSL.fields`' order.
|
||||
pub const OFFSETS: &'static [usize] = &[$(std::mem::offset_of!($name, $field)),*];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
uniform! {
|
||||
/// The frame, as `frame` in every stage. Positions and directions are
|
||||
/// in world axes relative to the camera (x east, y up, z south), so they
|
||||
/// stay precise far from the world's origin; depth is reversed (1 near,
|
||||
/// 0 at the far plane and in the sky).
|
||||
Frame {
|
||||
/// Camera-relative world to view space: the camera's rotation.
|
||||
view: [[f32; 4]; 4],
|
||||
/// View to clip space (reversed depth, wgpu's clip space: z 0..1).
|
||||
projection: [[f32; 4]; 4],
|
||||
/// View to camera-relative world space.
|
||||
view_inverse: [[f32; 4]; 4],
|
||||
/// Clip to view space.
|
||||
projection_inverse: [[f32; 4]; 4],
|
||||
/// The camera's world position, in blocks (about a millimetre of precision 10 000 blocks out).
|
||||
camera_position: [f32; 3],
|
||||
/// Seconds since the pack was loaded, wrapping to 0 every hour.
|
||||
time: f32,
|
||||
/// Unit vector toward the sun (below the horizon at night).
|
||||
sun_direction: [f32; 3],
|
||||
/// The time of day, 0..1: 0 sunrise, 0.25 noon, 0.5 sunset, 0.75 midnight.
|
||||
day_time: f32,
|
||||
/// Unit vector toward the moon.
|
||||
moon_direction: [f32; 3],
|
||||
/// Vanilla's GameTime: (game time % 24000 + partial tick) / 24000, 0..1.
|
||||
game_time: f32,
|
||||
/// Vanilla's sky colour this frame, 0..1.
|
||||
sky_color: [f32; 3],
|
||||
/// Rain strength, 0..1.
|
||||
rain: f32,
|
||||
/// Vanilla's fog colour this frame (also the clear colour), 0..1.
|
||||
fog_color: [f32; 3],
|
||||
/// Thunder strength, 0..1.
|
||||
thunder: f32,
|
||||
/// The size of the world's picture, in pixels (the resolution scale applied).
|
||||
resolution: [f32; 2],
|
||||
/// Block and sky light at the camera, 0..1 (level / 15).
|
||||
eye_light: [f32; 2],
|
||||
/// Where vanilla's fog starts, in blocks from the camera.
|
||||
fog_start: f32,
|
||||
/// Where vanilla's fog is full, in blocks.
|
||||
fog_end: f32,
|
||||
/// The render distance, in blocks.
|
||||
render_distance: f32,
|
||||
/// The far plane, in blocks.
|
||||
far: f32,
|
||||
/// Night vision strength, 0..1.
|
||||
night_vision: f32,
|
||||
/// Blindness strength, 0..1.
|
||||
blindness: f32,
|
||||
/// The darkness effect's strength, 0..1.
|
||||
darkness: f32,
|
||||
/// The Brightness option, 0..1 (Moody to Bright).
|
||||
brightness: f32,
|
||||
/// What the camera is in: 0 air, 1 water, 2 lava, 3 powder snow.
|
||||
eye_in: u32,
|
||||
/// 0 the Overworld, -1 the Nether, 1 the End, 2 any other dimension.
|
||||
dimension: i32,
|
||||
/// Frames drawn since the pack was loaded (wraps).
|
||||
frame_index: u32,
|
||||
/// 1 while F1 hides the HUD, else 0.
|
||||
hide_gui: u32,
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! fields {
|
||||
($($name:ident: $ty:literal = $doc:literal,)*) => {
|
||||
&[$(Field { name: stringify!($name), ty: $ty, doc: $doc }),*]
|
||||
};
|
||||
}
|
||||
|
||||
/// The post stage's input: one pixel of the picture.
|
||||
pub const POST_INPUT: Struct = Struct {
|
||||
name: "PostInput",
|
||||
doc: "One pixel of the picture, for `fn post(input: PostInput) -> vec4<f32>`.",
|
||||
fields: fields! {
|
||||
uv: "vec2<f32>" = "Where the pixel is, 0..1 from the top left.",
|
||||
pixel: "vec2<f32>" = "The pixel's centre, in pixels from the top left.",
|
||||
color: "vec4<f32>" = "The previous pass's colour here (the world's picture for the first pass).",
|
||||
depth: "f32" = "The world's depth here, reversed: 0 is the sky.",
|
||||
},
|
||||
};
|
||||
|
||||
/// The material stage's input: one point of a block's surface.
|
||||
pub const MATERIAL_INPUT: Struct = Struct {
|
||||
name: "MaterialInput",
|
||||
doc: "One point of a block's surface, for `fn material(input: MaterialInput) -> Surface`.",
|
||||
fields: fields! {
|
||||
albedo: "vec4<f32>" = "The block texture's colour here (alpha 0..1).",
|
||||
tint: "vec4<f32>" = "The vertex colour: biome tint, face shade and ambient occlusion.",
|
||||
light: "vec2<f32>" = "Block and sky light, 0..1, smoothed over the face.",
|
||||
lightmap: "vec3<f32>" = "Vanilla's light colour for that light this frame.",
|
||||
position: "vec3<f32>" = "The point, camera-relative, in blocks.",
|
||||
uv: "vec2<f32>" = "The texture coordinate inside the block atlas.",
|
||||
translucent: "bool" = "Drawn in the translucent pass (water, stained glass, ice).",
|
||||
},
|
||||
};
|
||||
|
||||
/// What the material stage returns.
|
||||
pub const SURFACE: Struct = Struct {
|
||||
name: "Surface",
|
||||
doc: "What `material` returns. Vanilla's own is `albedo * tint * vec4(lightmap, 1.0)`; \
|
||||
Horizon then applies the alpha test (cutout and translucent) and fog.",
|
||||
fields: fields! {
|
||||
color: "vec4<f32>" = "The lit colour, 0..1, with its alpha.",
|
||||
},
|
||||
};
|
||||
|
||||
/// The sky stage's input: one direction of the sky.
|
||||
pub const SKY_INPUT: Struct = Struct {
|
||||
name: "SkyInput",
|
||||
doc: "One direction of the sky, for `fn sky(input: SkyInput) -> vec4<f32>`. The sun, moon, \
|
||||
stars and clouds are drawn over the result.",
|
||||
fields: fields! {
|
||||
direction: "vec3<f32>" = "Unit view ray, camera-relative world axes.",
|
||||
color: "vec4<f32>" = "Vanilla's sky colour along the ray (sky, fog and sunrise).",
|
||||
},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn frame_fields_carry_their_docs() {
|
||||
let wgsl = Frame::WGSL;
|
||||
assert_eq!(wgsl.fields.len(), 28);
|
||||
assert!(wgsl.fields.iter().all(|f| !f.doc.trim().is_empty()));
|
||||
assert_eq!(std::mem::size_of::<Frame>(), 4 * 64 + 9 * 16);
|
||||
assert!(
|
||||
wgsl.wgsl()
|
||||
.contains(" camera_position: vec3<f32>,\n time: f32,\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Horizon's own shader packs (docs/api/shaders.md): WGSL written against
|
||||
//! the renderer's stages, described by `horizon-shaders.toml` and shipped as
|
||||
//! a folder or a `.hshader`. This crate reads and checks packs; the game, the
|
||||
//! launcher and the registry all use it, so a pack is judged the same way
|
||||
//! everywhere.
|
||||
pub mod check;
|
||||
pub mod inputs;
|
||||
pub mod manifest;
|
||||
pub mod package;
|
||||
pub mod reference;
|
||||
pub mod stages;
|
||||
|
||||
pub use check::{Problem, Report, check};
|
||||
pub use inputs::Frame;
|
||||
pub use manifest::{API, MANIFEST, Manifest, OptionKind, PackOption, SCHEMA, Stage};
|
||||
pub use package::{EXTENSION, Pack};
|
||||
pub use stages::{Composed, StageKind, compose};
|
||||
@@ -0,0 +1,365 @@
|
||||
//! `horizon-shaders.toml`, a shader pack's manifest (schema 1, docs/shaders.md).
|
||||
use serde::Deserialize;
|
||||
|
||||
/// The manifest's file name at the root of a pack.
|
||||
pub const MANIFEST: &str = "horizon-shaders.toml";
|
||||
/// The manifest schema this build reads.
|
||||
pub const SCHEMA: u32 = 1;
|
||||
/// The shader API line this build serves: the stages, their signatures and
|
||||
/// the inputs a pack's WGSL may read.
|
||||
pub const API: &str = "0.1";
|
||||
/// Full-screen passes a pack may chain.
|
||||
pub const MAX_POST_PASSES: usize = 8;
|
||||
/// Options a pack may declare.
|
||||
pub const MAX_OPTIONS: usize = 64;
|
||||
/// The most GPU time a pack may declare for itself, in milliseconds.
|
||||
pub const MAX_GPU_MS: f32 = 16.0;
|
||||
|
||||
/// A checked manifest.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Manifest {
|
||||
/// `[a-z0-9_]{1,64}`: the pack's settings key and registry id.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
/// The API line the pack is written for.
|
||||
pub api: String,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub license: Option<String>,
|
||||
/// WGSL files put before every stage's own file, in order.
|
||||
pub shared: Vec<String>,
|
||||
pub sky: Option<Stage>,
|
||||
pub material: Option<Stage>,
|
||||
/// Full-screen passes, in the order they run.
|
||||
pub post: Vec<Stage>,
|
||||
pub options: Vec<PackOption>,
|
||||
pub budget: Budget,
|
||||
}
|
||||
|
||||
/// One stage: the WGSL file that defines its function.
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Stage {
|
||||
pub file: String,
|
||||
/// Shown in the GPU time overlay; the file name when absent.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// An option the player can change: a WGSL `override` constant of the
|
||||
/// same name, set when the pipelines are built.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PackOption {
|
||||
/// The `override`'s name in the pack's WGSL.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub kind: OptionKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum OptionKind {
|
||||
/// `override <id>: bool`.
|
||||
Bool { default: bool },
|
||||
/// `override <id>: f32`; the slider moves by `step`.
|
||||
Float {
|
||||
default: f32,
|
||||
min: f32,
|
||||
max: f32,
|
||||
step: f32,
|
||||
},
|
||||
/// `override <id>: i32`.
|
||||
Int { default: i32, min: i32, max: i32 },
|
||||
/// `override <id>: u32`, the index of the chosen value.
|
||||
Choice { values: Vec<String>, default: u32 },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Budget {
|
||||
/// The GPU time the pack aims to add to a frame, in milliseconds; the
|
||||
/// overlay shows the pack over budget past it.
|
||||
pub gpu_ms: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Raw {
|
||||
schema: u32,
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
api: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
authors: Vec<String>,
|
||||
license: Option<String>,
|
||||
#[serde(default)]
|
||||
shared: Vec<String>,
|
||||
sky: Option<Stage>,
|
||||
material: Option<Stage>,
|
||||
#[serde(default)]
|
||||
post: Vec<Stage>,
|
||||
#[serde(default)]
|
||||
options: Vec<RawOption>,
|
||||
#[serde(default)]
|
||||
budget: RawBudget,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawOption {
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
default: toml::Value,
|
||||
min: Option<toml::Value>,
|
||||
max: Option<toml::Value>,
|
||||
step: Option<f64>,
|
||||
values: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RawBudget {
|
||||
gpu_ms: Option<f64>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn parse(text: &str) -> Result<Manifest, String> {
|
||||
let raw: Raw = toml::from_str(text).map_err(|e| format!("{MANIFEST}: {e}"))?;
|
||||
let err = |e: String| format!("{MANIFEST}: {e}");
|
||||
if raw.schema != SCHEMA {
|
||||
return Err(err(format!(
|
||||
"schema {}; this Horizon reads {SCHEMA}",
|
||||
raw.schema
|
||||
)));
|
||||
}
|
||||
if raw.api != API {
|
||||
return Err(err(format!(
|
||||
"written for shader API {}; this Horizon serves {API}",
|
||||
raw.api
|
||||
)));
|
||||
}
|
||||
let id_ok = (1..=64).contains(&raw.id.len())
|
||||
&& raw
|
||||
.id
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
|
||||
if !id_ok {
|
||||
return Err(err(format!("id {:?} is not [a-z0-9_]{{1,64}}", raw.id)));
|
||||
}
|
||||
if raw.name.trim().is_empty() || raw.version.trim().is_empty() {
|
||||
return Err(err("name and version can't be empty".into()));
|
||||
}
|
||||
if raw.sky.is_none() && raw.material.is_none() && raw.post.is_empty() {
|
||||
return Err(err(
|
||||
"no stage: give it a [sky], a [material] or a [[post]]".into()
|
||||
));
|
||||
}
|
||||
if raw.post.len() > MAX_POST_PASSES {
|
||||
return Err(err(format!(
|
||||
"{} post passes; a pack may chain {MAX_POST_PASSES}",
|
||||
raw.post.len()
|
||||
)));
|
||||
}
|
||||
let stages = raw.sky.iter().chain(&raw.material).chain(&raw.post);
|
||||
for file in raw.shared.iter().chain(stages.map(|s| &s.file)) {
|
||||
if !crate::package::plain_path(file) || !file.ends_with(".wgsl") {
|
||||
return Err(err(format!("{file:?} is not a plain path to a .wgsl file")));
|
||||
}
|
||||
}
|
||||
if raw.options.len() > MAX_OPTIONS {
|
||||
return Err(err(format!(
|
||||
"{} options; a pack may declare {MAX_OPTIONS}",
|
||||
raw.options.len()
|
||||
)));
|
||||
}
|
||||
let mut options = Vec::with_capacity(raw.options.len());
|
||||
for (i, o) in raw.options.into_iter().enumerate() {
|
||||
let id = o.id.clone();
|
||||
let option = o.check().map_err(|e| err(format!("option {id:?}: {e}")))?;
|
||||
if options[..i].iter().any(|p: &PackOption| p.id == option.id) {
|
||||
return Err(err(format!("option {id:?} is declared twice")));
|
||||
}
|
||||
options.push(option);
|
||||
}
|
||||
let gpu_ms = match raw.budget.gpu_ms {
|
||||
Some(ms) if !(ms > 0.0 && ms <= f64::from(MAX_GPU_MS)) => {
|
||||
return Err(err(format!(
|
||||
"budget.gpu_ms {ms} is not in (0, {MAX_GPU_MS}]"
|
||||
)));
|
||||
}
|
||||
ms => ms.map(|ms| ms as f32),
|
||||
};
|
||||
Ok(Manifest {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
version: raw.version,
|
||||
api: raw.api,
|
||||
description: raw.description,
|
||||
authors: raw.authors,
|
||||
license: raw.license,
|
||||
shared: raw.shared,
|
||||
sky: raw.sky,
|
||||
material: raw.material,
|
||||
post: raw.post,
|
||||
options,
|
||||
budget: Budget { gpu_ms },
|
||||
})
|
||||
}
|
||||
|
||||
/// Every WGSL file the manifest names, shared files first, each once.
|
||||
pub fn files(&self) -> Vec<&str> {
|
||||
let mut files: Vec<&str> = Vec::new();
|
||||
let stages = self.sky.iter().chain(&self.material).chain(&self.post);
|
||||
for file in self.shared.iter().chain(stages.map(|s| &s.file)) {
|
||||
if !files.contains(&file.as_str()) {
|
||||
files.push(file);
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
}
|
||||
|
||||
/// A WGSL identifier the renderer leaves to packs: ASCII, not starting
|
||||
/// with a digit, `_` or the renderer's own `horizon_` prefix.
|
||||
pub fn pack_identifier(name: &str) -> bool {
|
||||
let mut bytes = name.bytes();
|
||||
bytes.next().is_some_and(|b| b.is_ascii_alphabetic())
|
||||
&& bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
&& name.len() <= 64
|
||||
&& !name.starts_with("horizon_")
|
||||
}
|
||||
|
||||
impl RawOption {
|
||||
fn check(self) -> Result<PackOption, String> {
|
||||
if !pack_identifier(&self.id) {
|
||||
return Err(
|
||||
"the id must be a WGSL identifier: a letter, then letters, digits or _, up to 64, not starting with horizon_".into(),
|
||||
);
|
||||
}
|
||||
let int = |v: &toml::Value| {
|
||||
v.as_integer()
|
||||
.and_then(|i| i32::try_from(i).ok())
|
||||
.ok_or_else(|| format!("{v} is not a 32-bit integer"))
|
||||
};
|
||||
let float = |v: &toml::Value| {
|
||||
v.as_float()
|
||||
.or_else(|| v.as_integer().map(|i| i as f64))
|
||||
.filter(|f| f.is_finite())
|
||||
.map(|f| f as f32)
|
||||
.ok_or_else(|| format!("{v} is not a number"))
|
||||
};
|
||||
let need = |v: Option<toml::Value>, key: &str| v.ok_or_else(|| format!("needs {key}"));
|
||||
let unused = |present: bool, key: &str| {
|
||||
if present {
|
||||
Err(format!("{key} doesn't apply to type = {:?}", self.kind))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
let kind = match self.kind.as_str() {
|
||||
"bool" => {
|
||||
unused(self.min.is_some() || self.max.is_some(), "min/max")?;
|
||||
unused(self.step.is_some(), "step")?;
|
||||
unused(self.values.is_some(), "values")?;
|
||||
let default = self
|
||||
.default
|
||||
.as_bool()
|
||||
.ok_or("default must be true or false")?;
|
||||
OptionKind::Bool { default }
|
||||
}
|
||||
"float" => {
|
||||
unused(self.values.is_some(), "values")?;
|
||||
let (min, max) = (
|
||||
float(&need(self.min, "min")?)?,
|
||||
float(&need(self.max, "max")?)?,
|
||||
);
|
||||
let default = float(&self.default)?;
|
||||
let step = self.step.map_or((max - min) / 100.0, |s| s as f32);
|
||||
if !(min < max && (min..=max).contains(&default)) {
|
||||
return Err(format!("needs min < max and default in [{min}, {max}]"));
|
||||
}
|
||||
if !(step > 0.0 && step <= max - min) {
|
||||
return Err(format!("step {step} is not in (0, max - min]"));
|
||||
}
|
||||
OptionKind::Float {
|
||||
default,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
}
|
||||
}
|
||||
"int" => {
|
||||
unused(self.step.is_some(), "step")?;
|
||||
unused(self.values.is_some(), "values")?;
|
||||
let (min, max) = (int(&need(self.min, "min")?)?, int(&need(self.max, "max")?)?);
|
||||
let default = int(&self.default)?;
|
||||
if !(min < max && (min..=max).contains(&default)) {
|
||||
return Err(format!("needs min < max and default in [{min}, {max}]"));
|
||||
}
|
||||
OptionKind::Int { default, min, max }
|
||||
}
|
||||
"choice" => {
|
||||
unused(self.min.is_some() || self.max.is_some(), "min/max")?;
|
||||
unused(self.step.is_some(), "step")?;
|
||||
let values = self.values.ok_or("needs values")?;
|
||||
if !(2..=32).contains(&values.len()) {
|
||||
return Err("needs 2 to 32 values".into());
|
||||
}
|
||||
if values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(i, v)| v.trim().is_empty() || values[..i].contains(v))
|
||||
{
|
||||
return Err("values must be distinct and not empty".into());
|
||||
}
|
||||
let default = self
|
||||
.default
|
||||
.as_str()
|
||||
.and_then(|d| values.iter().position(|v| v == d))
|
||||
.ok_or("default must be one of the values")?;
|
||||
OptionKind::Choice {
|
||||
values,
|
||||
default: default as u32,
|
||||
}
|
||||
}
|
||||
other => {
|
||||
return Err(format!("type {other:?} is not bool, float, int or choice"));
|
||||
}
|
||||
};
|
||||
Ok(PackOption {
|
||||
id: self.id,
|
||||
name: self.name,
|
||||
description: self.description,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl OptionKind {
|
||||
/// The WGSL type of the option's `override`.
|
||||
pub fn wgsl_type(&self) -> &'static str {
|
||||
match self {
|
||||
OptionKind::Bool { .. } => "bool",
|
||||
OptionKind::Float { .. } => "f32",
|
||||
OptionKind::Int { .. } => "i32",
|
||||
OptionKind::Choice { .. } => "u32",
|
||||
}
|
||||
}
|
||||
|
||||
/// The default as the pipeline constant wgpu takes.
|
||||
pub fn default_constant(&self) -> f64 {
|
||||
match *self {
|
||||
OptionKind::Bool { default } => f64::from(u8::from(default)),
|
||||
OptionKind::Float { default, .. } => f64::from(default),
|
||||
OptionKind::Int { default, .. } => f64::from(default),
|
||||
OptionKind::Choice { default, .. } => f64::from(default),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! A shader pack on disk: a folder holding `horizon-shaders.toml`, or the
|
||||
//! same files zipped as a `.hshader` (what the registry and the launcher
|
||||
//! carry). Both read into one [`Pack`].
|
||||
use crate::manifest::{MANIFEST, Manifest};
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
io::{Read, Write},
|
||||
path::Path,
|
||||
};
|
||||
|
||||
/// A packaged pack's extension.
|
||||
pub const EXTENSION: &str = "hshader";
|
||||
/// Bytes per WGSL file, per other file, and in all.
|
||||
pub const MAX_WGSL: u64 = 256 << 10;
|
||||
const MAX_OTHER: u64 = 1 << 20;
|
||||
pub const MAX_TOTAL: u64 = 8 << 20;
|
||||
/// Files in a `.hshader`.
|
||||
const MAX_ENTRIES: usize = 512;
|
||||
/// Files a pack may hold beside its manifest and WGSL.
|
||||
const EXTRAS: [&str; 5] = [
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"LICENSE.md",
|
||||
"LICENSE.txt",
|
||||
"icon.png",
|
||||
];
|
||||
|
||||
/// A pack read from a folder or a `.hshader`: its manifest and every file,
|
||||
/// by path from the pack's root (`/`-separated).
|
||||
#[derive(Debug)]
|
||||
pub struct Pack {
|
||||
pub manifest: Manifest,
|
||||
pub files: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A relative path made of plain parts: no root, `..`, `\`, empty or
|
||||
/// hidden parts.
|
||||
pub fn plain_path(path: &str) -> bool {
|
||||
!path.is_empty()
|
||||
&& path.split('/').all(|part| {
|
||||
!part.is_empty()
|
||||
&& !part.starts_with('.')
|
||||
&& part
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
|
||||
})
|
||||
}
|
||||
|
||||
impl Pack {
|
||||
/// Reads `path`: a `.hshader` file, or a folder (its hidden files and
|
||||
/// folders, such as `.git`, are skipped).
|
||||
pub fn read(path: &Path) -> Result<Pack, String> {
|
||||
let mut files = BTreeMap::new();
|
||||
if path.is_dir() {
|
||||
read_dir(path, "", &mut files)?;
|
||||
} else {
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file)
|
||||
.map_err(|e| format!("{}: not a .{EXTENSION} (zip): {e}", path.display()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(format!("over {MAX_ENTRIES} files in the package"));
|
||||
}
|
||||
for i in 0..zip.len() {
|
||||
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
|
||||
if entry.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.name().to_owned();
|
||||
let mut bytes = Vec::new();
|
||||
(&mut entry)
|
||||
.take(MAX_OTHER.max(MAX_WGSL) + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| format!("{name}: {e}"))?;
|
||||
if files.insert(name.clone(), bytes).is_some() {
|
||||
return Err(format!("{name} is in the package twice"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Pack::from_files(files)
|
||||
}
|
||||
|
||||
/// Checks a pack's files: a manifest, the WGSL it names (as UTF-8),
|
||||
/// nothing it doesn't use, and sizes within bounds.
|
||||
pub fn from_files(files: BTreeMap<String, Vec<u8>>) -> Result<Pack, String> {
|
||||
let text = files
|
||||
.get(MANIFEST)
|
||||
.ok_or_else(|| format!("{MANIFEST}: missing at the pack's root"))?;
|
||||
let manifest = Manifest::parse(
|
||||
std::str::from_utf8(text).map_err(|_| format!("{MANIFEST}: not UTF-8"))?,
|
||||
)?;
|
||||
let used = manifest.files();
|
||||
let mut total = 0;
|
||||
for (name, bytes) in &files {
|
||||
if !plain_path(name) {
|
||||
return Err(format!("{name:?} is not a plain path"));
|
||||
}
|
||||
let size = bytes.len() as u64;
|
||||
total += size;
|
||||
let max = if name.ends_with(".wgsl") {
|
||||
if !used.contains(&name.as_str()) {
|
||||
return Err(format!("{name} is not used by any stage or in shared"));
|
||||
}
|
||||
if std::str::from_utf8(bytes).is_err() {
|
||||
return Err(format!("{name}: not UTF-8"));
|
||||
}
|
||||
MAX_WGSL
|
||||
} else if name == MANIFEST || EXTRAS.contains(&name.as_str()) {
|
||||
MAX_OTHER
|
||||
} else {
|
||||
return Err(format!(
|
||||
"{name}: a pack holds only {MANIFEST}, .wgsl files and {}",
|
||||
EXTRAS.join(", ")
|
||||
));
|
||||
};
|
||||
if size > max {
|
||||
return Err(format!("{name} is over {} KiB", max >> 10));
|
||||
}
|
||||
}
|
||||
if total > MAX_TOTAL {
|
||||
return Err(format!("the pack is over {} MiB", MAX_TOTAL >> 20));
|
||||
}
|
||||
if let Some(missing) = used.iter().find(|f| !files.contains_key(**f)) {
|
||||
return Err(format!("{missing}: named in {MANIFEST} but missing"));
|
||||
}
|
||||
Ok(Pack { manifest, files })
|
||||
}
|
||||
|
||||
/// A WGSL file's text (every file the manifest names is checked UTF-8).
|
||||
pub fn source(&self, file: &str) -> Option<&str> {
|
||||
self.files
|
||||
.get(file)
|
||||
.and_then(|b| std::str::from_utf8(b).ok())
|
||||
}
|
||||
|
||||
/// The `.hshader` bytes: the files in path order, with fixed times and
|
||||
/// permissions, so the same pack always gives the same bytes (and the
|
||||
/// same SHA-256).
|
||||
pub fn package(&self) -> Result<Vec<u8>, String> {
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.last_modified_time(zip::DateTime::default())
|
||||
.unix_permissions(0o644);
|
||||
let err = |e: zip::result::ZipError| e.to_string();
|
||||
for (name, bytes) in &self.files {
|
||||
zip.start_file(name.as_str(), options).map_err(err)?;
|
||||
zip.write_all(bytes).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(zip.finish().map_err(err)?.into_inner())
|
||||
}
|
||||
|
||||
/// The SHA-256 of [`Pack::package`], in lowercase hex: the pack's
|
||||
/// identity in the registry.
|
||||
pub fn sha256(&self) -> Result<String, String> {
|
||||
Ok(sha256_hex(&self.package()?))
|
||||
}
|
||||
}
|
||||
|
||||
/// SHA-256 in lowercase hex.
|
||||
pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_dir(dir: &Path, prefix: &str, files: &mut BTreeMap<String, Vec<u8>>) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let rel = format!("{prefix}{name}");
|
||||
if path.is_dir() {
|
||||
read_dir(&path, &format!("{rel}/"), files)?;
|
||||
} else {
|
||||
let size = entry.metadata().map_err(|e| format!("{rel}: {e}"))?.len();
|
||||
if size > MAX_TOTAL {
|
||||
return Err(format!("{rel} is over {} MiB", MAX_TOTAL >> 20));
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|e| format!("{rel}: {e}"))?;
|
||||
files.insert(rel, bytes);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! docs/api/shaders-reference.md, generated from the stages and inputs this
|
||||
//! crate defines (`tests/reference.rs` fails when the file drifts).
|
||||
use crate::check::{MAX_EXPRESSIONS, MAX_TEXTURE_READS};
|
||||
use crate::inputs::{Frame, SURFACE, Struct};
|
||||
use crate::manifest::{API, MAX_GPU_MS, MAX_OPTIONS, MAX_POST_PASSES};
|
||||
use crate::package::{MAX_TOTAL, MAX_WGSL};
|
||||
use crate::stages::{GROUP, StageKind};
|
||||
|
||||
fn table(s: &Struct, offsets: Option<&[usize]>) -> String {
|
||||
let mut out = format!("### `{}`\n\n{}\n\n", s.name, one_line(s.doc));
|
||||
out += if offsets.is_some() {
|
||||
"| Field | Type | Offset | Meaning |\n| --- | --- | --- | --- |\n"
|
||||
} else {
|
||||
"| Field | Type | Meaning |\n| --- | --- | --- |\n"
|
||||
};
|
||||
for (i, f) in s.fields.iter().enumerate() {
|
||||
let offset = offsets.map_or(String::new(), |o| format!(" {} |", o[i]));
|
||||
out += &format!(
|
||||
"| `{}` | `{}` |{offset} {} |\n",
|
||||
f.name,
|
||||
f.ty,
|
||||
one_line(f.doc)
|
||||
);
|
||||
}
|
||||
out + "\n"
|
||||
}
|
||||
|
||||
fn one_line(doc: &str) -> String {
|
||||
doc.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
pub fn markdown() -> String {
|
||||
let mut out = format!(
|
||||
"# Shader API reference ({API})\n\n\
|
||||
Generated from `crates/horizon-shader-api` (`HORIZON_WRITE_DOCS=1 cargo test -p horizon-shader-api --test reference`\n\
|
||||
rewrites it; the test fails when it drifts). The API's design and pack format: [shaders.md](shaders.md).\n\n\
|
||||
A stage is a function your WGSL defines, with the signature below; Horizon puts the prelude (these structs, the\n\
|
||||
resources and helpers) before your files and calls the function from its own pass. Your files declare no\n\
|
||||
bindings and no entry points, and no names starting with `horizon_`.\n\n\
|
||||
## Stages\n\n\
|
||||
| Stage | In `horizon-shaders.toml` | Your function |\n| --- | --- | --- |\n"
|
||||
);
|
||||
for kind in StageKind::ALL {
|
||||
let key = match kind {
|
||||
StageKind::Post => "[[post]]",
|
||||
StageKind::Sky => "[sky]",
|
||||
StageKind::Material => "[material]",
|
||||
};
|
||||
out += &format!("| {} | `{key}` | `{}` |\n", kind.name(), kind.signature());
|
||||
}
|
||||
out += "\n";
|
||||
for kind in StageKind::ALL {
|
||||
out += &format!("## {}\n\n`{}`\n\n", kind.name(), kind.signature());
|
||||
out += &table(kind.input(), None);
|
||||
if kind == StageKind::Material {
|
||||
out += &table(&SURFACE, None);
|
||||
}
|
||||
out += &format!(
|
||||
"Resources (bind group {GROUP}):\n\n| Binding | Declaration | Meaning |\n| --- | --- | --- |\n"
|
||||
);
|
||||
for r in kind.resources() {
|
||||
out += &format!("| {} | `{}` | {} |\n", r.binding, r.ty, r.doc);
|
||||
}
|
||||
out += "\n";
|
||||
if kind == StageKind::Post {
|
||||
out += "Helpers:\n\n\
|
||||
- `fn depth_at(uv: vec2<f32>) -> f32`: the world's depth at `uv`.\n\
|
||||
- `fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32>`: the camera-relative position, in blocks, of\n \
|
||||
the point at `uv` and `depth`.\n\n";
|
||||
}
|
||||
}
|
||||
out += "## The frame\n\n";
|
||||
out += &table(&Frame::WGSL, Some(Frame::OFFSETS));
|
||||
out += &format!(
|
||||
"## Options\n\n\
|
||||
Each `[[options]]` entry sets the WGSL `override` of the same name when the pipelines are built:\n\n\
|
||||
| `type` | Keys | The `override` |\n| --- | --- | --- |\n\
|
||||
| `bool` | `default` | `override <id>: bool` |\n\
|
||||
| `float` | `default`, `min`, `max`, `step` | `override <id>: f32` |\n\
|
||||
| `int` | `default`, `min`, `max` | `override <id>: i32` |\n\
|
||||
| `choice` | `values`, `default` (one of them) | `override <id>: u32`, the value's index |\n\n\
|
||||
## Limits\n\n\
|
||||
- {MAX_POST_PASSES} post passes, {MAX_OPTIONS} options, `budget.gpu_ms` up to {MAX_GPU_MS}.\n\
|
||||
- {} KiB per WGSL file, {} MiB per pack.\n\
|
||||
- Per stage: {MAX_EXPRESSIONS} naga expressions over all its functions, {MAX_TEXTURE_READS} texture reads (samples,\n \
|
||||
gathers and loads, the prelude's included).\n\n\
|
||||
## The check\n\n\
|
||||
A pack runs only after it passes `horizon_shader_api::check` (the game when it loads a pack, the launcher before it\n\
|
||||
installs one, the registry's CI): each stage is parsed and validated by naga, checked against its signature and\n\
|
||||
the rules above, and translated for Vulkan (SPIR-V), Metal and DirectX 12 (HLSL) with its options at their\n\
|
||||
defaults, minimums and maximums. Every option must be an `override` of its type in some stage; an `override`\n\
|
||||
that is no option needs a default.\n",
|
||||
MAX_WGSL >> 10,
|
||||
MAX_TOTAL >> 20
|
||||
);
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The stages a pack can fill, each a WGSL function with a fixed signature
|
||||
//! that Horizon calls inside its own passes, and the WGSL put around a
|
||||
//! pack's files to build a stage: the prelude before (the inputs and the
|
||||
//! resources), the entry points after.
|
||||
use crate::inputs::{Frame, MATERIAL_INPUT, POST_INPUT, SKY_INPUT, SURFACE, Struct};
|
||||
use crate::package::Pack;
|
||||
|
||||
/// The bind group of a pack's resources; groups 0 to 2 are the renderer's.
|
||||
pub const GROUP: u32 = 3;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StageKind {
|
||||
/// The sky's colour along each view ray, before the sun, moon and stars.
|
||||
Sky,
|
||||
/// The colour of block surfaces (terrain), before the alpha test and fog.
|
||||
Material,
|
||||
/// A full-screen pass over the world's picture, under the hand and HUD.
|
||||
Post,
|
||||
}
|
||||
|
||||
/// A resource a stage reads, declared by its prelude.
|
||||
#[derive(Debug)]
|
||||
pub struct Resource {
|
||||
pub binding: u32,
|
||||
pub name: &'static str,
|
||||
pub ty: &'static str,
|
||||
pub doc: &'static str,
|
||||
}
|
||||
|
||||
const FRAME: Resource = Resource {
|
||||
binding: 0,
|
||||
name: "frame",
|
||||
ty: "var<uniform> frame: Frame",
|
||||
doc: "The frame (`Frame`).",
|
||||
};
|
||||
|
||||
const POST_RESOURCES: [Resource; 6] = [
|
||||
FRAME,
|
||||
Resource {
|
||||
binding: 1,
|
||||
name: "scene_color",
|
||||
ty: "var scene_color: texture_2d<f32>",
|
||||
doc: "The world's picture before any pass.",
|
||||
},
|
||||
Resource {
|
||||
binding: 2,
|
||||
name: "scene_depth",
|
||||
ty: "var scene_depth: texture_depth_2d",
|
||||
doc: "The world's depth, reversed (1 near, 0 far and sky).",
|
||||
},
|
||||
Resource {
|
||||
binding: 3,
|
||||
name: "previous",
|
||||
ty: "var previous: texture_2d<f32>",
|
||||
doc: "The previous pass's output (`scene_color` for the first pass).",
|
||||
},
|
||||
Resource {
|
||||
binding: 4,
|
||||
name: "linear_sampler",
|
||||
ty: "var linear_sampler: sampler",
|
||||
doc: "Bilinear filtering, clamped to the edges.",
|
||||
},
|
||||
Resource {
|
||||
binding: 5,
|
||||
name: "nearest_sampler",
|
||||
ty: "var nearest_sampler: sampler",
|
||||
doc: "Nearest texel, clamped to the edges.",
|
||||
},
|
||||
];
|
||||
|
||||
/// Functions the post prelude gives packs.
|
||||
const POST_HELPERS: &str = "\
|
||||
// The world's depth at `uv` (reversed: 0 is the sky).
|
||||
fn depth_at(uv: vec2<f32>) -> f32 {
|
||||
let size = vec2<f32>(textureDimensions(scene_depth));
|
||||
let texel = vec2<i32>(clamp(uv * size, vec2<f32>(0.0), size - 1.0));
|
||||
return textureLoad(scene_depth, texel, 0);
|
||||
}
|
||||
// The camera-relative world position, in blocks, of the point at `uv` and `depth`.
|
||||
fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32> {
|
||||
let clip = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
|
||||
let view = frame.projection_inverse * clip;
|
||||
return (frame.view_inverse * vec4<f32>(view.xyz / view.w, 0.0)).xyz;
|
||||
}
|
||||
";
|
||||
|
||||
/// The post stage's entry points, as the renderer builds them: a full-screen
|
||||
/// triangle, and a fragment that calls the pack's `post` for each pixel.
|
||||
const POST_ENTRIES: &str = "\
|
||||
struct HorizonPostOut {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
@vertex fn horizon_vs_post(@builtin(vertex_index) i: u32) -> HorizonPostOut {
|
||||
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
||||
var out: HorizonPostOut;
|
||||
out.position = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
|
||||
out.uv = vec2<f32>(p.x, 1.0 - p.y);
|
||||
return out;
|
||||
}
|
||||
@fragment fn horizon_fs_post(in: HorizonPostOut) -> @location(0) vec4<f32> {
|
||||
let color = textureSampleLevel(previous, linear_sampler, in.uv, 0.0);
|
||||
return post(PostInput(in.uv, in.position.xy, color, depth_at(in.uv)));
|
||||
}
|
||||
";
|
||||
|
||||
/// Entry points with the material stage's inputs: the renderer calls
|
||||
/// `material` from its own terrain shader in the same way.
|
||||
const MATERIAL_ENTRIES: &str = "\
|
||||
struct HorizonMaterialIn {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) albedo: vec4<f32>,
|
||||
@location(1) tint: vec4<f32>,
|
||||
@location(2) light: vec2<f32>,
|
||||
@location(3) lightmap: vec3<f32>,
|
||||
@location(4) world: vec3<f32>,
|
||||
@location(5) uv: vec2<f32>,
|
||||
@location(6) @interpolate(flat) translucent: u32,
|
||||
}
|
||||
@fragment fn horizon_fs_material(in: HorizonMaterialIn) -> @location(0) vec4<f32> {
|
||||
let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u);
|
||||
return material(input).color;
|
||||
}
|
||||
";
|
||||
|
||||
/// Entry points with the sky stage's inputs: the renderer calls `sky` from
|
||||
/// its own sky shader in the same way.
|
||||
const SKY_ENTRIES: &str = "\
|
||||
struct HorizonSkyIn {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) direction: vec3<f32>,
|
||||
@location(1) color: vec4<f32>,
|
||||
}
|
||||
@fragment fn horizon_fs_sky(in: HorizonSkyIn) -> @location(0) vec4<f32> {
|
||||
return sky(SkyInput(normalize(in.direction), in.color));
|
||||
}
|
||||
";
|
||||
|
||||
impl StageKind {
|
||||
pub const ALL: [StageKind; 3] = [StageKind::Sky, StageKind::Material, StageKind::Post];
|
||||
|
||||
/// The manifest's name for the stage (`[sky]`, `[material]`, `[[post]]`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
StageKind::Sky => "sky",
|
||||
StageKind::Material => "material",
|
||||
StageKind::Post => "post",
|
||||
}
|
||||
}
|
||||
|
||||
/// The function a pack defines: it has the stage's name.
|
||||
pub fn function(self) -> &'static str {
|
||||
self.name()
|
||||
}
|
||||
|
||||
pub fn input(self) -> &'static Struct {
|
||||
match self {
|
||||
StageKind::Sky => &SKY_INPUT,
|
||||
StageKind::Material => &MATERIAL_INPUT,
|
||||
StageKind::Post => &POST_INPUT,
|
||||
}
|
||||
}
|
||||
|
||||
/// The type the function returns.
|
||||
pub fn output(self) -> &'static str {
|
||||
match self {
|
||||
StageKind::Material => SURFACE.name,
|
||||
StageKind::Sky | StageKind::Post => "vec4<f32>",
|
||||
}
|
||||
}
|
||||
|
||||
/// `fn <name>(input: <Input>) -> <Output>`.
|
||||
pub fn signature(self) -> String {
|
||||
format!(
|
||||
"fn {}(input: {}) -> {}",
|
||||
self.function(),
|
||||
self.input().name,
|
||||
self.output()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resources(self) -> &'static [Resource] {
|
||||
match self {
|
||||
StageKind::Post => &POST_RESOURCES,
|
||||
StageKind::Sky | StageKind::Material => std::slice::from_ref(&FRAME),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the pack's files may use, declared before them.
|
||||
pub fn prelude(self) -> String {
|
||||
let mut out =
|
||||
String::from("// Horizon shader API prelude (docs/api/shaders-reference.md)\n");
|
||||
out += &Frame::WGSL.wgsl();
|
||||
out += &self.input().wgsl();
|
||||
if self == StageKind::Material {
|
||||
out += &SURFACE.wgsl();
|
||||
}
|
||||
for r in self.resources() {
|
||||
out += &format!("@group({GROUP}) @binding({}) {};\n", r.binding, r.ty);
|
||||
}
|
||||
if self == StageKind::Post {
|
||||
out += POST_HELPERS;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The entry points put after the pack's files.
|
||||
pub fn entries(self) -> &'static str {
|
||||
match self {
|
||||
StageKind::Sky => SKY_ENTRIES,
|
||||
StageKind::Material => MATERIAL_ENTRIES,
|
||||
StageKind::Post => POST_ENTRIES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stage's WGSL, and where each part of it came from.
|
||||
#[derive(Debug)]
|
||||
pub struct Composed {
|
||||
pub text: String,
|
||||
/// (the file, or `<prelude>` / `<entries>`, its byte range in `text`).
|
||||
pub parts: Vec<(String, std::ops::Range<usize>)>,
|
||||
}
|
||||
|
||||
impl Composed {
|
||||
/// The file, line and column (1-based) of a byte offset in `text`.
|
||||
pub fn locate(&self, offset: usize) -> (&str, usize, usize) {
|
||||
let (file, range) = self
|
||||
.parts
|
||||
.iter()
|
||||
.find(|(_, r)| r.contains(&offset))
|
||||
.or(self.parts.last())
|
||||
.expect("a composed stage has parts");
|
||||
let before = &self.text[range.start..offset.clamp(range.start, range.end)];
|
||||
let line = before.matches('\n').count() + 1;
|
||||
let column = before.len() - before.rfind('\n').map_or(0, |i| i + 1) + 1;
|
||||
(file, line, column)
|
||||
}
|
||||
}
|
||||
|
||||
/// The WGSL of the stage `kind` whose own file is `file`: the prelude, the
|
||||
/// pack's shared files in order, the file, then the entry points.
|
||||
pub fn compose(pack: &Pack, kind: StageKind, file: &str) -> Composed {
|
||||
let mut composed = Composed {
|
||||
text: String::new(),
|
||||
parts: Vec::new(),
|
||||
};
|
||||
let mut push = |name: &str, text: &str| {
|
||||
let start = composed.text.len();
|
||||
composed.text += text;
|
||||
if !text.ends_with('\n') {
|
||||
composed.text.push('\n');
|
||||
}
|
||||
composed
|
||||
.parts
|
||||
.push((name.to_owned(), start..composed.text.len()));
|
||||
};
|
||||
push("<prelude>", &kind.prelude());
|
||||
for shared in pack.manifest.shared.iter().filter(|s| *s != file) {
|
||||
push(shared, pack.source(shared).unwrap_or_default());
|
||||
}
|
||||
push(file, pack.source(file).unwrap_or_default());
|
||||
push("<entries>", kind.entries());
|
||||
composed
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
schema = 1
|
||||
id = "{{id}}"
|
||||
name = "{{name}}"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
description = "How block surfaces are lit."
|
||||
|
||||
[material]
|
||||
file = "material.wgsl"
|
||||
|
||||
[[options]]
|
||||
id = "contrast"
|
||||
name = "Light contrast"
|
||||
type = "float"
|
||||
default = 1.2
|
||||
min = 0.5
|
||||
max = 2.0
|
||||
step = 0.05
|
||||
@@ -0,0 +1,9 @@
|
||||
// The colour of each point of a block's surface, before the alpha test and
|
||||
// fog. Vanilla's own: albedo * tint * vec4(lightmap, 1.0).
|
||||
// Reference: docs/api/shaders-reference.md.
|
||||
override contrast: f32 = 1.2;
|
||||
|
||||
fn material(input: MaterialInput) -> Surface {
|
||||
let light = pow(input.lightmap, vec3<f32>(contrast));
|
||||
return Surface(input.albedo * input.tint * vec4<f32>(light, 1.0));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
schema = 1
|
||||
id = "{{id}}"
|
||||
name = "{{name}}"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
description = "A post-processing pass over the world."
|
||||
|
||||
[[post]]
|
||||
file = "post.wgsl"
|
||||
name = "Colour"
|
||||
|
||||
[[options]]
|
||||
id = "saturation"
|
||||
name = "Saturation"
|
||||
type = "float"
|
||||
default = 1.15
|
||||
min = 0.0
|
||||
max = 2.0
|
||||
step = 0.05
|
||||
|
||||
[budget]
|
||||
gpu_ms = 0.5
|
||||
@@ -0,0 +1,9 @@
|
||||
// Runs once per pixel of the world's picture, under the hand and the HUD.
|
||||
// Reference: docs/api/shaders-reference.md (the inputs, `frame` and the helpers).
|
||||
override saturation: f32 = 1.15;
|
||||
|
||||
fn post(input: PostInput) -> vec4<f32> {
|
||||
let grey = dot(input.color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
let color = mix(vec3<f32>(grey), input.color.rgb, saturation);
|
||||
return vec4<f32>(color, input.color.a);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
schema = 1
|
||||
id = "{{id}}"
|
||||
name = "{{name}}"
|
||||
version = "0.1.0"
|
||||
api = "0.1"
|
||||
description = "A sky of its own."
|
||||
|
||||
[sky]
|
||||
file = "sky.wgsl"
|
||||
|
||||
[[options]]
|
||||
id = "glow"
|
||||
name = "Sun glow"
|
||||
type = "float"
|
||||
default = 0.5
|
||||
min = 0.0
|
||||
max = 1.0
|
||||
step = 0.05
|
||||
@@ -0,0 +1,9 @@
|
||||
// The sky's colour along each view ray; the sun, moon, stars and clouds are
|
||||
// drawn over it. Reference: docs/api/shaders-reference.md.
|
||||
override glow: f32 = 0.5;
|
||||
|
||||
fn sky(input: SkyInput) -> vec4<f32> {
|
||||
let toward_sun = max(dot(input.direction, frame.sun_direction), 0.0);
|
||||
let warm = vec3<f32>(1.0, 0.6, 0.3) * pow(toward_sun, 8.0) * glow;
|
||||
return vec4<f32>(input.color.rgb + warm, input.color.a);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! `check`: the sample pack passes, and each kind of mistake is reported
|
||||
//! with the pack's own file and line.
|
||||
use horizon_shader_api::{MANIFEST, Pack, Problem, check};
|
||||
use std::path::Path;
|
||||
|
||||
fn sample() -> Pack {
|
||||
Pack::read(Path::new(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/samples/schema-1"
|
||||
)))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The sample with `file` replaced by `text`, checked.
|
||||
fn problems(file: &str, text: &str) -> Vec<Problem> {
|
||||
let mut files = sample().files;
|
||||
files.insert(file.into(), text.as_bytes().to_vec());
|
||||
check(&Pack::from_files(files).unwrap()).expect_err("should fail")
|
||||
}
|
||||
|
||||
fn one(file: &str, text: &str, needle: &str) -> Problem {
|
||||
let p = problems(file, text);
|
||||
assert!(
|
||||
p.iter().any(|p| p.message.contains(needle)),
|
||||
"{needle:?} not in {p:#?}"
|
||||
);
|
||||
p.into_iter().find(|p| p.message.contains(needle)).unwrap()
|
||||
}
|
||||
|
||||
const GRADE: &str = "post/grade.wgsl";
|
||||
|
||||
#[test]
|
||||
fn the_sample_passes() {
|
||||
let pack = sample();
|
||||
let report = check(&pack).unwrap();
|
||||
let labels: Vec<_> = report.stages.iter().map(|s| s.label.as_str()).collect();
|
||||
assert_eq!(
|
||||
labels,
|
||||
["sky.wgsl", "Terrain", "Colour grade", "post/vignette.wgsl"]
|
||||
);
|
||||
assert!(report.stages.iter().all(|s| s.expressions > 0));
|
||||
let post = &report.stages[2];
|
||||
assert!(post.texture_reads >= 2, "the entry point samples and loads");
|
||||
let sha = pack.sha256().unwrap();
|
||||
assert_eq!(sha.len(), 64);
|
||||
assert_eq!(sha, sample().sha256().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_syntax_error_points_at_its_line() {
|
||||
let p = one(
|
||||
GRADE,
|
||||
"override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\nfn post(input: PostInput) -> vec4<f32> {\n return input.color +;\n}\n",
|
||||
"expected",
|
||||
);
|
||||
assert_eq!((p.file.as_str(), p.line), (GRADE, 4), "{p}");
|
||||
assert!(p.to_string().starts_with("post/grade.wgsl:4:"), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stage_function_must_have_its_signature() {
|
||||
let header = "override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\n";
|
||||
one(
|
||||
GRADE,
|
||||
&format!("{header}fn post(input: PostInput) -> vec3<f32> {{ return input.color.rgb; }}\n"),
|
||||
"`fn post(PostInput) -> vec3<f32>` should be `fn post(input: PostInput) -> vec4<f32>`",
|
||||
);
|
||||
one(
|
||||
GRADE,
|
||||
&format!("{header}fn grade(input: PostInput) -> vec4<f32> {{ return input.color; }}\n"),
|
||||
"defines no `fn post(input: PostInput) -> vec4<f32>`",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_declares_no_bindings_entry_points_or_horizon_names() {
|
||||
let body = "override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\nfn post(input: PostInput) -> vec4<f32> { return input.color; }\n";
|
||||
one(
|
||||
GRADE,
|
||||
&format!("@group(3) @binding(9) var extra: texture_2d<f32>;\n{body}"),
|
||||
"declares the binding `extra`",
|
||||
);
|
||||
one(
|
||||
GRADE,
|
||||
&format!(
|
||||
"{body}@fragment fn main() -> @location(0) vec4<f32> {{ return vec4<f32>(1.0); }}\n"
|
||||
),
|
||||
"declares the entry point `main`",
|
||||
);
|
||||
one(
|
||||
GRADE,
|
||||
&format!("{body}fn horizon_mine() {{}}\n"),
|
||||
"`horizon_mine`: names starting with horizon_",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_match_the_options() {
|
||||
let post = "fn post(input: PostInput) -> vec4<f32> { return input.color; }\n";
|
||||
one(
|
||||
GRADE,
|
||||
&format!("override saturation: i32 = 1;\noverride tonemap: u32 = 1u;\n{post}"),
|
||||
"option \"saturation\" is a float, so its override is `f32`",
|
||||
);
|
||||
one(
|
||||
GRADE,
|
||||
&format!(
|
||||
"override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\noverride extra: f32;\n{post}"
|
||||
),
|
||||
"`override extra` has no default and no [[options]] entry",
|
||||
);
|
||||
let p = one(
|
||||
GRADE,
|
||||
&format!("override saturation: f32 = 1.1;\n{post}"),
|
||||
"option \"tonemap\": no stage declares `override tonemap: u32`",
|
||||
);
|
||||
assert_eq!(p.file, MANIFEST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_errors_point_into_the_pack() {
|
||||
// A compute-only barrier in a fragment stage passes the parser, not the validator.
|
||||
let p = one(
|
||||
GRADE,
|
||||
"override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\n\
|
||||
fn post(input: PostInput) -> vec4<f32> {\n \
|
||||
workgroupBarrier();\n \
|
||||
return input.color;\n}\n",
|
||||
"forbidden at this stage",
|
||||
);
|
||||
assert_eq!(p.file, GRADE, "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_reads_have_a_budget() {
|
||||
let reads: String = (0..70)
|
||||
.map(|i| format!(" c += textureLoad(previous, vec2<i32>({i}, 0), 0);\n"))
|
||||
.collect();
|
||||
one(
|
||||
GRADE,
|
||||
&format!(
|
||||
"override saturation: f32 = 1.1;\noverride tonemap: u32 = 1u;\nfn post(input: PostInput) -> vec4<f32> {{\n var c = input.color;\n{reads} return c;\n}}\n"
|
||||
),
|
||||
"texture reads; a stage may write 64",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! The stages' WGSL against the Rust definitions: the preludes compile, the
|
||||
//! frame's WGSL layout is the uploaded struct's, and
|
||||
//! docs/api/shaders-reference.md is up to date. Rewrite the reference with
|
||||
//! `HORIZON_WRITE_DOCS=1 cargo test -p horizon-shader-api --test reference`.
|
||||
use horizon_shader_api::{Frame, Pack, StageKind, compose, reference};
|
||||
use std::path::Path;
|
||||
|
||||
const DOC: &str = "docs/api/shaders-reference.md";
|
||||
|
||||
fn root() -> &'static Path {
|
||||
Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
|
||||
}
|
||||
|
||||
fn validate(text: &str) -> naga::Module {
|
||||
let module = naga::front::wgsl::parse_str(text)
|
||||
.unwrap_or_else(|e| panic!("{}\n{text}", e.emit_to_string(text)));
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::default(),
|
||||
)
|
||||
.validate(&module)
|
||||
.unwrap_or_else(|e| panic!("{}\n{text}", e.emit_to_string(text)));
|
||||
module
|
||||
}
|
||||
|
||||
/// Vanilla's look, written as each stage.
|
||||
fn vanilla(kind: StageKind) -> &'static str {
|
||||
match kind {
|
||||
StageKind::Sky => "fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
|
||||
StageKind::Material => {
|
||||
"fn material(input: MaterialInput) -> Surface {\n \
|
||||
return Surface(input.albedo * input.tint * vec4<f32>(input.lightmap, 1.0));\n}\n"
|
||||
}
|
||||
StageKind::Post => "fn post(input: PostInput) -> vec4<f32> { return input.color; }\n",
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_stage_compiles_with_vanilla_s_function() {
|
||||
for kind in StageKind::ALL {
|
||||
let text = format!("{}{}{}", kind.prelude(), vanilla(kind), kind.entries());
|
||||
let module = validate(&text);
|
||||
assert!(
|
||||
module
|
||||
.entry_points
|
||||
.iter()
|
||||
.all(|e| e.name.starts_with("horizon_")),
|
||||
"{kind:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_frame_s_wgsl_layout_is_the_uploaded_struct_s() {
|
||||
let text = format!("{}{}", StageKind::Sky.prelude(), vanilla(StageKind::Sky));
|
||||
let module = validate(&text);
|
||||
let (_, ty) = module
|
||||
.types
|
||||
.iter()
|
||||
.find(|(_, t)| t.name.as_deref() == Some("Frame"))
|
||||
.expect("Frame in the prelude");
|
||||
let naga::TypeInner::Struct { members, span } = &ty.inner else {
|
||||
panic!("Frame is a struct")
|
||||
};
|
||||
assert_eq!(*span as usize, std::mem::size_of::<Frame>());
|
||||
let wgsl: Vec<_> = members
|
||||
.iter()
|
||||
.map(|m| (m.name.clone().unwrap(), m.offset as usize))
|
||||
.collect();
|
||||
let rust: Vec<_> = Frame::WGSL
|
||||
.fields
|
||||
.iter()
|
||||
.zip(Frame::OFFSETS)
|
||||
.map(|(f, &o)| (f.name.to_owned(), o))
|
||||
.collect();
|
||||
assert_eq!(wgsl, rust);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sample_pack_s_stages_compile() {
|
||||
let dir = root().join("crates/horizon-shader-api/tests/samples/schema-1");
|
||||
let pack = Pack::read(&dir).unwrap();
|
||||
let m = &pack.manifest;
|
||||
let stages = [
|
||||
(StageKind::Sky, m.sky.iter().collect::<Vec<_>>()),
|
||||
(StageKind::Material, m.material.iter().collect()),
|
||||
(StageKind::Post, m.post.iter().collect()),
|
||||
];
|
||||
for (kind, list) in stages {
|
||||
for stage in list {
|
||||
let composed = compose(&pack, kind, &stage.file);
|
||||
validate(&composed.text);
|
||||
// Errors point into the pack's own file.
|
||||
let part = composed.parts.iter().find(|(f, _)| *f == stage.file);
|
||||
let part = part.unwrap().1.clone();
|
||||
let needle = format!("fn {}(", kind.function());
|
||||
let at = part.start + composed.text[part].find(&needle).unwrap();
|
||||
let source = pack.source(&stage.file).unwrap();
|
||||
let line = source.lines().position(|l| l.starts_with(&needle)).unwrap() + 1;
|
||||
assert_eq!(composed.locate(at), (stage.file.as_str(), line, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reference_is_up_to_date() {
|
||||
let path = root().join(DOC);
|
||||
let want = reference::markdown();
|
||||
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
|
||||
std::fs::write(&path, &want).unwrap();
|
||||
}
|
||||
let have = std::fs::read_to_string(&path)
|
||||
.unwrap_or_default()
|
||||
.replace("\r\n", "\n");
|
||||
assert!(
|
||||
have == want,
|
||||
"{DOC} is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-shader-api --test reference"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
The schema 1 sample read by `tests/schema.rs`; `tests/reference.rs` compiles its stages.
|
||||
@@ -0,0 +1,3 @@
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Schema 1 of horizon-shaders.toml with every key. Readers of later schemas
|
||||
# keep reading this file (docs/api/stability.md, "Data formats").
|
||||
schema = 1
|
||||
id = "sample_pack"
|
||||
name = "Sample Pack"
|
||||
version = "1.0.0"
|
||||
api = "0.1"
|
||||
description = "Every key of schema 1."
|
||||
authors = ["Horizon"]
|
||||
license = "MIT"
|
||||
shared = ["common.wgsl"]
|
||||
|
||||
[sky]
|
||||
file = "sky.wgsl"
|
||||
|
||||
[material]
|
||||
file = "material.wgsl"
|
||||
name = "Terrain"
|
||||
|
||||
[[post]]
|
||||
file = "post/grade.wgsl"
|
||||
name = "Colour grade"
|
||||
|
||||
[[post]]
|
||||
file = "post/vignette.wgsl"
|
||||
|
||||
[[options]]
|
||||
id = "saturation"
|
||||
name = "Saturation"
|
||||
description = "How colourful the picture is."
|
||||
type = "float"
|
||||
default = 1.1
|
||||
min = 0.0
|
||||
max = 2.0
|
||||
step = 0.05
|
||||
|
||||
[[options]]
|
||||
id = "vignette"
|
||||
name = "Vignette"
|
||||
type = "bool"
|
||||
default = true
|
||||
|
||||
[[options]]
|
||||
id = "samples"
|
||||
name = "Samples"
|
||||
type = "int"
|
||||
default = 4
|
||||
min = 1
|
||||
max = 16
|
||||
|
||||
[[options]]
|
||||
id = "tonemap"
|
||||
name = "Tone mapping"
|
||||
type = "choice"
|
||||
values = ["None", "Soft", "Filmic"]
|
||||
default = "Soft"
|
||||
|
||||
[budget]
|
||||
gpu_ms = 1.5
|
||||
@@ -0,0 +1,4 @@
|
||||
fn material(input: MaterialInput) -> Surface {
|
||||
let lit = input.albedo * input.tint * vec4<f32>(input.lightmap, 1.0);
|
||||
return Surface(vec4<f32>(lit.rgb * (0.9 + 0.1 * luminance(lit.rgb)), lit.a));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
override saturation: f32 = 1.1;
|
||||
override tonemap: u32 = 1u;
|
||||
|
||||
fn post(input: PostInput) -> vec4<f32> {
|
||||
let c = input.color.rgb;
|
||||
let graded = mix(vec3<f32>(luminance(c)), c, saturation);
|
||||
return vec4<f32>(select(graded, graded / (graded + 0.5) * 1.5, tonemap > 0u), input.color.a);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
override vignette: bool = true;
|
||||
override samples: i32 = 4;
|
||||
|
||||
fn post(input: PostInput) -> vec4<f32> {
|
||||
let d = distance(input.uv, vec2<f32>(0.5));
|
||||
let dark = select(1.0, 1.0 - smoothstep(0.4, 0.9, d) * 0.5, vignette);
|
||||
return vec4<f32>(input.color.rgb * dark, input.color.a);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn sky(input: SkyInput) -> vec4<f32> {
|
||||
return input.color;
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
//! Schema 1 of `horizon-shaders.toml` and the `.hshader` package.
|
||||
use horizon_shader_api::{MANIFEST, Manifest, OptionKind, Pack};
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
fn sample_dir() -> &'static Path {
|
||||
Path::new(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/samples/schema-1"
|
||||
))
|
||||
}
|
||||
|
||||
fn sample_text() -> String {
|
||||
std::fs::read_to_string(sample_dir().join(MANIFEST)).unwrap()
|
||||
}
|
||||
|
||||
fn refused(text: &str) -> String {
|
||||
Manifest::parse(text).expect_err("should be refused")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_every_key_of_schema_1() {
|
||||
let m = Manifest::parse(&sample_text()).unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
m.id.as_str(),
|
||||
m.name.as_str(),
|
||||
m.version.as_str(),
|
||||
m.api.as_str()
|
||||
),
|
||||
("sample_pack", "Sample Pack", "1.0.0", "0.1")
|
||||
);
|
||||
assert_eq!(m.authors, ["Horizon"]);
|
||||
assert_eq!(m.license.as_deref(), Some("MIT"));
|
||||
assert_eq!(m.sky.as_ref().unwrap().file, "sky.wgsl");
|
||||
assert_eq!(
|
||||
m.material.as_ref().unwrap().name.as_deref(),
|
||||
Some("Terrain")
|
||||
);
|
||||
let post: Vec<_> = m.post.iter().map(|s| s.file.as_str()).collect();
|
||||
assert_eq!(post, ["post/grade.wgsl", "post/vignette.wgsl"]);
|
||||
assert_eq!(
|
||||
m.files(),
|
||||
[
|
||||
"common.wgsl",
|
||||
"sky.wgsl",
|
||||
"material.wgsl",
|
||||
"post/grade.wgsl",
|
||||
"post/vignette.wgsl"
|
||||
]
|
||||
);
|
||||
let kinds: Vec<_> = m.options.iter().map(|o| (o.id.as_str(), &o.kind)).collect();
|
||||
assert_eq!(
|
||||
kinds,
|
||||
[
|
||||
(
|
||||
"saturation",
|
||||
&OptionKind::Float {
|
||||
default: 1.1,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.05
|
||||
}
|
||||
),
|
||||
("vignette", &OptionKind::Bool { default: true }),
|
||||
(
|
||||
"samples",
|
||||
&OptionKind::Int {
|
||||
default: 4,
|
||||
min: 1,
|
||||
max: 16
|
||||
}
|
||||
),
|
||||
(
|
||||
"tonemap",
|
||||
&OptionKind::Choice {
|
||||
values: vec!["None".into(), "Soft".into(), "Filmic".into()],
|
||||
default: 1
|
||||
}
|
||||
),
|
||||
]
|
||||
);
|
||||
let types: Vec<_> = m.options.iter().map(|o| o.kind.wgsl_type()).collect();
|
||||
assert_eq!(types, ["f32", "bool", "i32", "u32"]);
|
||||
let constants: Vec<_> = m
|
||||
.options
|
||||
.iter()
|
||||
.map(|o| o.kind.default_constant())
|
||||
.collect();
|
||||
assert_eq!(constants, [f64::from(1.1f32), 1.0, 4.0, 1.0]);
|
||||
assert_eq!(m.budget.gpu_ms, Some(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_smallest_manifest() {
|
||||
let m = Manifest::parse(
|
||||
"schema = 1\nid = \"p\"\nname = \"P\"\nversion = \"1\"\napi = \"0.1\"\n[[post]]\nfile = \"a.wgsl\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(m.sky.is_none() && m.options.is_empty() && m.budget.gpu_ms.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_what_this_build_does_not_read() {
|
||||
let text = sample_text();
|
||||
let cases = [
|
||||
(text.replace("schema = 1", "schema = 2"), "schema 2"),
|
||||
(
|
||||
text.replace("api = \"0.1\"", "api = \"0.2\""),
|
||||
"shader API 0.2",
|
||||
),
|
||||
(
|
||||
text.replace("\"sample_pack\"", "\"Sample\""),
|
||||
"is not [a-z0-9_]",
|
||||
),
|
||||
(
|
||||
text.replace("license = \"MIT\"", "licence = \"MIT\""),
|
||||
"unknown field",
|
||||
),
|
||||
(
|
||||
text.replace("gpu_ms = 1.5", "gpu_ms = 40.0"),
|
||||
"budget.gpu_ms",
|
||||
),
|
||||
(
|
||||
text.replace("\"common.wgsl\"", "\"../common.wgsl\""),
|
||||
"not a plain path",
|
||||
),
|
||||
(
|
||||
text.replace("\"sky.wgsl\"", "\"sky.glsl\""),
|
||||
"not a plain path to a .wgsl",
|
||||
),
|
||||
(
|
||||
text.replace("id = \"samples\"", "id = \"vignette\""),
|
||||
"declared twice",
|
||||
),
|
||||
(
|
||||
text.replace("id = \"samples\"", "id = \"horizon_samples\""),
|
||||
"WGSL identifier",
|
||||
),
|
||||
(
|
||||
text.replace("default = \"Soft\"", "default = \"Hard\""),
|
||||
"one of the values",
|
||||
),
|
||||
(
|
||||
text.replace("default = 1.1", "default = 3.0"),
|
||||
"default in [0, 2]",
|
||||
),
|
||||
(
|
||||
text.replace("default = 4", "default = 4.5"),
|
||||
"not a 32-bit integer",
|
||||
),
|
||||
(
|
||||
text.replace("type = \"bool\"", "type = \"color\""),
|
||||
"is not bool",
|
||||
),
|
||||
(
|
||||
text.replace("default = true", "default = true\nmin = 0"),
|
||||
"doesn't apply",
|
||||
),
|
||||
];
|
||||
for (text, needle) in cases {
|
||||
let e = refused(&text);
|
||||
assert!(e.contains(needle), "{needle:?} not in {e:?}");
|
||||
assert!(e.starts_with(MANIFEST), "{e}");
|
||||
}
|
||||
let no_stage = "schema = 1\nid = \"p\"\nname = \"P\"\nversion = \"1\"\napi = \"0.1\"\n";
|
||||
assert!(refused(no_stage).contains("no stage"));
|
||||
let many = (0..9).map(|i| format!("[[post]]\nfile = \"p{i}.wgsl\"\n"));
|
||||
assert!(refused(&format!("{no_stage}{}", many.collect::<String>())).contains("9 post passes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_sample_folder_and_its_package_alike() {
|
||||
let folder = Pack::read(sample_dir()).unwrap();
|
||||
assert_eq!(folder.files.len(), 7);
|
||||
assert!(
|
||||
folder
|
||||
.source("post/grade.wgsl")
|
||||
.unwrap()
|
||||
.contains("fn post")
|
||||
);
|
||||
let bytes = folder.package().unwrap();
|
||||
assert_eq!(
|
||||
bytes,
|
||||
folder.package().unwrap(),
|
||||
"packaging is reproducible"
|
||||
);
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shader-api-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("sample_pack.hshader");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let packaged = Pack::read(&path).unwrap();
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
assert_eq!(packaged.files, folder.files);
|
||||
assert_eq!(packaged.manifest, folder.manifest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_files_a_pack_does_not_use() {
|
||||
let base = || -> BTreeMap<String, Vec<u8>> { Pack::read(sample_dir()).unwrap().files };
|
||||
let mut files = base();
|
||||
files.insert("old.wgsl".into(), b"fn f() {}".to_vec());
|
||||
assert!(
|
||||
Pack::from_files(files)
|
||||
.unwrap_err()
|
||||
.contains("old.wgsl is not used")
|
||||
);
|
||||
let mut files = base();
|
||||
files.insert("run.sh".into(), Vec::new());
|
||||
assert!(
|
||||
Pack::from_files(files)
|
||||
.unwrap_err()
|
||||
.contains("run.sh: a pack holds only")
|
||||
);
|
||||
let mut files = base();
|
||||
files.remove("sky.wgsl");
|
||||
assert!(
|
||||
Pack::from_files(files)
|
||||
.unwrap_err()
|
||||
.contains("sky.wgsl: named in")
|
||||
);
|
||||
let mut files = base();
|
||||
files.insert("sky.wgsl".into(), vec![0xff, 0xfe]);
|
||||
assert!(Pack::from_files(files).unwrap_err().contains("not UTF-8"));
|
||||
let mut files = base();
|
||||
files.insert("post/grade.wgsl".into(), vec![b' '; 300 << 10]);
|
||||
assert!(
|
||||
Pack::from_files(files)
|
||||
.unwrap_err()
|
||||
.contains("over 256 KiB")
|
||||
);
|
||||
let mut files = base();
|
||||
files.remove(MANIFEST);
|
||||
assert!(
|
||||
Pack::from_files(files)
|
||||
.unwrap_err()
|
||||
.contains("missing at the pack's root")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! The SDK: every template and the example pack pass the check, and the
|
||||
//! `horizon-shader` tool makes, checks and packages a pack.
|
||||
use horizon_shader_api::{Pack, check};
|
||||
use std::{path::Path, process::Command};
|
||||
|
||||
const TOOL: &str = env!("CARGO_BIN_EXE_horizon-shader");
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shader-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> (bool, String) {
|
||||
let out = Command::new(TOOL).args(args).output().unwrap();
|
||||
let text =
|
||||
String::from_utf8_lossy(&out.stdout).into_owned() + &String::from_utf8_lossy(&out.stderr);
|
||||
(out.status.success(), text)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_example_pack_passes() {
|
||||
let dir = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/packs/golden_hour"));
|
||||
let report = check(&Pack::read(dir).unwrap()).unwrap();
|
||||
let labels: Vec<_> = report.stages.iter().map(|s| s.label.as_str()).collect();
|
||||
assert_eq!(labels, ["Warm horizon", "Glow", "Colour grade"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_template_makes_a_pack_that_passes() {
|
||||
let root = scratch("templates");
|
||||
for template in ["post", "sky", "material"] {
|
||||
let dir = root.join(format!("My-{template}"));
|
||||
let dir_s = dir.to_str().unwrap();
|
||||
let (ok, text) = run(&["new", dir_s, "--template", template]);
|
||||
assert!(ok, "{text}");
|
||||
let pack = Pack::read(&dir).unwrap();
|
||||
assert_eq!(pack.manifest.id, format!("my_{template}"));
|
||||
assert_eq!(pack.manifest.name, format!("My {template}"));
|
||||
let (ok, text) = run(&["check", dir_s]);
|
||||
assert!(ok && text.contains(": ok"), "{text}");
|
||||
}
|
||||
let (ok, text) = run(&[
|
||||
"new",
|
||||
root.join("x").to_str().unwrap(),
|
||||
"--template",
|
||||
"water",
|
||||
]);
|
||||
assert!(!ok && text.contains("no template \"water\""), "{text}");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packs_and_checks_the_package() {
|
||||
let root = scratch("pack");
|
||||
let dir = root.join("glow");
|
||||
let dir_s = dir.to_str().unwrap();
|
||||
assert!(run(&["new", dir_s, "--name", "Glow"]).0);
|
||||
let out = root.join("glow.hshader");
|
||||
let (ok, text) = run(&["pack", dir_s, "--out", out.to_str().unwrap()]);
|
||||
assert!(ok, "{text}");
|
||||
let sha = text
|
||||
.split("sha256 ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.trim_end_matches([')', '\n'])
|
||||
.to_owned();
|
||||
let (ok, json) = run(&["check", out.to_str().unwrap(), "--json"]);
|
||||
assert!(ok, "{json}");
|
||||
let report: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(report[0]["ok"], true);
|
||||
assert_eq!(report[0]["id"], "glow");
|
||||
assert_eq!(report[0]["sha256"], sha.as_str());
|
||||
assert_eq!(report[0]["stages"][0]["stage"], "post");
|
||||
|
||||
std::fs::write(
|
||||
dir.join("post.wgsl"),
|
||||
"fn post(input: PostInput) -> vec4<f32> { return input.color }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let (ok, json) = run(&["check", dir_s, "--json"]);
|
||||
assert!(!ok);
|
||||
let report: serde_json::Value =
|
||||
serde_json::from_str(json.split("\nerror:").next().unwrap()).unwrap();
|
||||
assert_eq!(report[0]["ok"], false);
|
||||
assert_eq!(report[0]["problems"][0]["file"], "post.wgsl");
|
||||
assert_eq!(report[0]["problems"][0]["line"], 1);
|
||||
let (ok, text) = run(&["pack", dir_s]);
|
||||
assert!(!ok && text.contains("failed the check"), "{text}");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -7,6 +7,8 @@ rust-version = "1.85"
|
||||
description = "Shader packs (the shaders/ folder format): reading packs, their .properties and .lang files, and later their GLSL"
|
||||
|
||||
[dependencies]
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
naga = { version = "30.0.1", features = ["glsl-in", "wgsl-in"] }
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
//! The integer arithmetic of `#if` and `#elif` (spec 1.5, GLSL 4.60 §3.3): `defined`, integer
|
||||
//! literals, macros expanded to their values, and the C operators with C precedence.
|
||||
//! An identifier that is not a macro counts as 0, as in C: GLSL would reject the line, and a
|
||||
//! pack would then lose a whole section of its settings over one unknown name.
|
||||
|
||||
use crate::Macros;
|
||||
use crate::macros::{is_ident, is_ident_start};
|
||||
|
||||
/// The value of a condition; `Err` says what is wrong with it.
|
||||
pub fn eval_condition(expr: &str, macros: &Macros) -> Result<i64, String> {
|
||||
let tokens = tokens(&resolve_defined(expr, macros)?, macros)?;
|
||||
let mut parser = Parser {
|
||||
tokens: &tokens,
|
||||
at: 0,
|
||||
};
|
||||
let value = parser.binary(0)?;
|
||||
match parser.tokens.get(parser.at) {
|
||||
None => Ok(value),
|
||||
Some(t) => Err(format!("unexpected `{}`", t.text())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text after a line's `#`, split into the directive word and the rest without its `//`
|
||||
/// comment. `None` when the first word is followed by something other than white space, `//` or
|
||||
/// the end of the line (`#ifdefault=1` is a comment, not `#ifdef`).
|
||||
pub(crate) fn split_directive(after_hash: &str) -> Option<(&str, &str)> {
|
||||
let line = after_hash.trim_start();
|
||||
let len = line
|
||||
.find(|c: char| !c.is_ascii_alphabetic())
|
||||
.unwrap_or(line.len());
|
||||
let (word, rest) = line.split_at(len);
|
||||
let ok = rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with("//");
|
||||
ok.then(|| (word, rest.split("//").next().unwrap_or("").trim()))
|
||||
}
|
||||
|
||||
/// One level of `#if` ... `#endif`.
|
||||
struct Branch {
|
||||
/// This branch's lines are read.
|
||||
on: bool,
|
||||
/// A branch of this `#if` was already taken (the rest are skipped).
|
||||
taken: bool,
|
||||
/// The enclosing level is read.
|
||||
outer: bool,
|
||||
seen_else: bool,
|
||||
}
|
||||
|
||||
/// The state of nested conditional directives.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Conditionals(Vec<Branch>);
|
||||
|
||||
impl Conditionals {
|
||||
/// Lines here are read.
|
||||
pub(crate) fn active(&self) -> bool {
|
||||
self.0.last().is_none_or(|b| b.on)
|
||||
}
|
||||
|
||||
/// An `#if` is still open.
|
||||
pub(crate) fn open(&self) -> bool {
|
||||
!self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Applies `#word rest` when it is a conditional directive (`Ok(false)` when it is not). A
|
||||
/// condition that can't be evaluated reads as false and returns why.
|
||||
pub(crate) fn apply(
|
||||
&mut self,
|
||||
word: &str,
|
||||
rest: &str,
|
||||
macros: &Macros,
|
||||
) -> Result<bool, String> {
|
||||
let on = self.active();
|
||||
match word {
|
||||
"if" | "ifdef" | "ifndef" => {
|
||||
let value = if on {
|
||||
test(word, rest, macros)
|
||||
} else {
|
||||
Ok(false)
|
||||
};
|
||||
let v = *value.as_ref().unwrap_or(&false);
|
||||
self.0.push(Branch {
|
||||
on: v,
|
||||
taken: v,
|
||||
outer: on,
|
||||
seen_else: false,
|
||||
});
|
||||
value.map(|_| true)
|
||||
}
|
||||
"elif" | "else" => {
|
||||
let b = self
|
||||
.0
|
||||
.last_mut()
|
||||
.ok_or_else(|| format!("`#{word}` without `#if`"))?;
|
||||
if b.seen_else {
|
||||
return Err(format!("`#{word}` after `#else`"));
|
||||
}
|
||||
b.seen_else = word == "else";
|
||||
let take = b.outer && !b.taken;
|
||||
let value = match word {
|
||||
"else" => Ok(take),
|
||||
_ if take => test("if", rest, macros),
|
||||
_ => Ok(false),
|
||||
};
|
||||
b.on = *value.as_ref().unwrap_or(&false);
|
||||
b.taken |= b.on;
|
||||
value.map(|_| true)
|
||||
}
|
||||
"endif" => self
|
||||
.0
|
||||
.pop()
|
||||
.map(|_| true)
|
||||
.ok_or_else(|| "`#endif` without `#if`".into()),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn test(word: &str, rest: &str, macros: &Macros) -> Result<bool, String> {
|
||||
let name = rest.split_whitespace().next().unwrap_or("");
|
||||
match word {
|
||||
"ifdef" => Ok(macros.is_defined(name)),
|
||||
"ifndef" => Ok(!macros.is_defined(name)),
|
||||
_ => eval_condition(rest, macros)
|
||||
.map(|v| v != 0)
|
||||
.map_err(|e| format!("`#{word} {rest}`: {e}; read as false")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `defined X` and `defined(X)` become `1` or `0` before anything is expanded.
|
||||
fn resolve_defined(expr: &str, macros: &Macros) -> Result<String, String> {
|
||||
let mut out = String::with_capacity(expr.len());
|
||||
let mut rest = expr;
|
||||
while let Some(at) = find_word(rest, "defined") {
|
||||
out.push_str(&rest[..at]);
|
||||
let after = rest[at + "defined".len()..].trim_start();
|
||||
let (inner, tail) = match after.strip_prefix('(') {
|
||||
Some(open) => {
|
||||
let close = open.find(')').ok_or("`defined(` without `)`")?;
|
||||
(open[..close].trim(), &open[close + 1..])
|
||||
}
|
||||
None => {
|
||||
let len = after.find(|c: char| !is_ident(c)).unwrap_or(after.len());
|
||||
(&after[..len], &after[len..])
|
||||
}
|
||||
};
|
||||
if inner.is_empty() || !inner.starts_with(is_ident_start) || !inner.chars().all(is_ident) {
|
||||
return Err("`defined` needs a macro name".into());
|
||||
}
|
||||
out.push_str(if macros.is_defined(inner) {
|
||||
" 1 "
|
||||
} else {
|
||||
" 0 "
|
||||
});
|
||||
rest = tail;
|
||||
}
|
||||
out.push_str(rest);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Where `word` starts in `text` as a whole identifier.
|
||||
fn find_word(text: &str, word: &str) -> Option<usize> {
|
||||
let mut from = 0;
|
||||
while let Some(i) = text[from..].find(word).map(|i| i + from) {
|
||||
let before = text[..i].chars().next_back().is_some_and(is_ident);
|
||||
let after = text[i + word.len()..].chars().next().is_some_and(is_ident);
|
||||
if !before && !after {
|
||||
return Some(i);
|
||||
}
|
||||
from = i + word.len();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Token {
|
||||
Num(i64),
|
||||
Op(&'static str),
|
||||
}
|
||||
|
||||
impl Token {
|
||||
fn text(&self) -> String {
|
||||
match self {
|
||||
Token::Num(n) => n.to_string(),
|
||||
Token::Op(op) => (*op).to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const OPS: [&str; 22] = [
|
||||
"<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "(", ")", "!", "~", "+", "-", "*", "/", "%",
|
||||
"<", ">", "&", "^", "|",
|
||||
];
|
||||
|
||||
fn tokens(expr: &str, macros: &Macros) -> Result<Vec<Token>, String> {
|
||||
let expanded = macros.expand(expr);
|
||||
let mut out = Vec::new();
|
||||
let mut rest = expanded.trim_start();
|
||||
while let Some(c) = rest.chars().next() {
|
||||
if c.is_ascii_digit() {
|
||||
let len = rest
|
||||
.find(|c: char| !c.is_ascii_alphanumeric())
|
||||
.unwrap_or(rest.len());
|
||||
if rest[len..].starts_with('.') {
|
||||
return Err(format!("`{}`: `#if` takes integers only", &rest[..len + 1]));
|
||||
}
|
||||
out.push(Token::Num(int_literal(&rest[..len])?));
|
||||
rest = &rest[len..];
|
||||
} else if is_ident_start(c) {
|
||||
// Not a macro: 0, as the C preprocessor counts it.
|
||||
let len = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len());
|
||||
out.push(Token::Num(0));
|
||||
rest = &rest[len..];
|
||||
} else {
|
||||
let op = OPS
|
||||
.iter()
|
||||
.find(|op| rest.starts_with(**op))
|
||||
.ok_or_else(|| format!("unexpected `{c}`"))?;
|
||||
out.push(Token::Op(op));
|
||||
rest = &rest[op.len()..];
|
||||
}
|
||||
rest = rest.trim_start();
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decimal, `0x` hexadecimal or leading-zero octal, with an optional `u` suffix.
|
||||
fn int_literal(text: &str) -> Result<i64, String> {
|
||||
let digits = text.strip_suffix(['u', 'U']).unwrap_or(text);
|
||||
let parsed = if let Some(hex) = digits.strip_prefix("0x").or(digits.strip_prefix("0X")) {
|
||||
i64::from_str_radix(hex, 16)
|
||||
} else if digits.len() > 1 && digits.starts_with('0') {
|
||||
i64::from_str_radix(&digits[1..], 8)
|
||||
} else {
|
||||
digits.parse()
|
||||
};
|
||||
parsed.map_err(|_| format!("`{text}` is not an integer"))
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
tokens: &'a [Token],
|
||||
at: usize,
|
||||
}
|
||||
|
||||
/// Binary operators from the loosest to the tightest (GLSL §5.1 / §3.3).
|
||||
const LEVELS: [&[&str]; 10] = [
|
||||
&["||"],
|
||||
&["&&"],
|
||||
&["|"],
|
||||
&["^"],
|
||||
&["&"],
|
||||
&["==", "!="],
|
||||
&["<", ">", "<=", ">="],
|
||||
&["<<", ">>"],
|
||||
&["+", "-"],
|
||||
&["*", "/", "%"],
|
||||
];
|
||||
|
||||
impl Parser<'_> {
|
||||
fn next(&mut self) -> Option<Token> {
|
||||
let t = self.tokens.get(self.at).copied();
|
||||
self.at += 1;
|
||||
t
|
||||
}
|
||||
|
||||
fn binary(&mut self, level: usize) -> Result<i64, String> {
|
||||
if level == LEVELS.len() {
|
||||
return self.unary();
|
||||
}
|
||||
let mut left = self.binary(level + 1)?;
|
||||
while let Some(&Token::Op(op)) = self.tokens.get(self.at) {
|
||||
if !LEVELS[level].contains(&op) {
|
||||
break;
|
||||
}
|
||||
self.at += 1;
|
||||
let right = self.binary(level + 1)?;
|
||||
left = apply(op, left, right)?;
|
||||
}
|
||||
Ok(left)
|
||||
}
|
||||
|
||||
fn unary(&mut self) -> Result<i64, String> {
|
||||
match self.next() {
|
||||
Some(Token::Num(n)) => Ok(n),
|
||||
Some(Token::Op("(")) => {
|
||||
let value = self.binary(0)?;
|
||||
match self.next() {
|
||||
Some(Token::Op(")")) => Ok(value),
|
||||
_ => Err("missing `)`".into()),
|
||||
}
|
||||
}
|
||||
Some(Token::Op("-")) => Ok(self.unary()?.wrapping_neg()),
|
||||
Some(Token::Op("+")) => self.unary(),
|
||||
Some(Token::Op("!")) => Ok((self.unary()? == 0) as i64),
|
||||
Some(Token::Op("~")) => Ok(!self.unary()?),
|
||||
Some(t) => Err(format!("unexpected `{}`", t.text())),
|
||||
None => Err("the condition ends too early".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(op: &str, a: i64, b: i64) -> Result<i64, String> {
|
||||
Ok(match op {
|
||||
"||" => (a != 0 || b != 0) as i64,
|
||||
"&&" => (a != 0 && b != 0) as i64,
|
||||
"|" => a | b,
|
||||
"^" => a ^ b,
|
||||
"&" => a & b,
|
||||
"==" => (a == b) as i64,
|
||||
"!=" => (a != b) as i64,
|
||||
"<" => (a < b) as i64,
|
||||
">" => (a > b) as i64,
|
||||
"<=" => (a <= b) as i64,
|
||||
">=" => (a >= b) as i64,
|
||||
"<<" => a.wrapping_shl(b as u32),
|
||||
">>" => a.wrapping_shr(b as u32),
|
||||
"+" => a.wrapping_add(b),
|
||||
"-" => a.wrapping_sub(b),
|
||||
"*" => a.wrapping_mul(b),
|
||||
"/" | "%" if b == 0 => return Err("division by zero".into()),
|
||||
"/" => a.wrapping_div(b),
|
||||
_ => a.wrapping_rem(b),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn eval(expr: &str) -> Result<i64, String> {
|
||||
let mut m = Macros::standard();
|
||||
m.define("SHADOW_RES", "2048");
|
||||
m.define("WAVES", "");
|
||||
m.define("QUALITY", "SHADOW_RES / 1024");
|
||||
eval_condition(expr, &m)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedence_and_operators_follow_c() {
|
||||
assert_eq!(eval("1 + 2 * 3"), Ok(7));
|
||||
assert_eq!(eval("(1 + 2) * 3"), Ok(9));
|
||||
assert_eq!(eval("1 << 2 + 1"), Ok(8));
|
||||
assert_eq!(eval("6 & 3 == 3"), Ok(0));
|
||||
assert_eq!(eval("1 || 0 && 0"), Ok(1));
|
||||
assert_eq!(eval("-7 / 2"), Ok(-3));
|
||||
assert_eq!(eval("-7 % 2"), Ok(-1));
|
||||
assert_eq!(eval("!0 + ~0"), Ok(0));
|
||||
assert_eq!(eval("0x10 + 010 + 3u"), Ok(27));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_and_defined() {
|
||||
assert_eq!(eval("MC_VERSION >= 11300"), Ok(1));
|
||||
assert_eq!(eval("MC_VERSION <= 12111"), Ok(0));
|
||||
assert_eq!(eval("QUALITY == 2"), Ok(1));
|
||||
assert_eq!(eval("defined WAVES && defined(SHADOW_RES)"), Ok(1));
|
||||
assert_eq!(eval("!defined NOPE"), Ok(1));
|
||||
assert_eq!(eval("NOPE == 0"), Ok(1));
|
||||
assert_eq!(eval("undefinedFeature"), Ok(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_conditions_say_why() {
|
||||
assert!(eval("1 / 0").is_err());
|
||||
assert!(eval("SHADOW_RES == 0.5").is_err());
|
||||
assert!(eval("(1 + 2").is_err());
|
||||
assert!(eval("1 2").is_err());
|
||||
assert!(eval("").is_err());
|
||||
assert!(eval("defined").is_err());
|
||||
assert!(eval("1 @ 2").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//! The configuration packs write into their stage files (spec 1.7, 6.1): which buffers a fragment
|
||||
//! shader writes (`/* DRAWBUFFERS:0257 */`, `/* RENDERTARGETS: 0,2 */`) and, per colortex buffer,
|
||||
//! its format (`const int colortex2Format = RGBA16F;`, inside a block comment because the format
|
||||
//! names are not GLSL), whether and to what it is cleared, and whether it gets mipmaps.
|
||||
//!
|
||||
//! Everything is read from live code only: the preprocessor has already dropped the false
|
||||
//! branches, so a format chosen by an option is the one the option selects. Every stage file of
|
||||
//! the programs that run is read (OQ-17); a later file overrides an earlier one, with a note when
|
||||
//! the values differ.
|
||||
|
||||
use crate::Preprocessed;
|
||||
|
||||
/// colortex buffers (6.1.1).
|
||||
pub const BUFFERS: usize = 16;
|
||||
|
||||
/// A colour format of 6.7, as channels, bits per channel and kind. Packed formats are described
|
||||
/// by their widest channel: `RGB10_A2` is 4 × 10 bits, `R11F_G11F_B10F` 3 × 11 bits float.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Format {
|
||||
pub channels: u8,
|
||||
pub bits: u8,
|
||||
pub kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Unorm,
|
||||
Snorm,
|
||||
Float,
|
||||
Int,
|
||||
Uint,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The default `RGBA` (6.1.3).
|
||||
pub const RGBA8: Format = Format {
|
||||
channels: 4,
|
||||
bits: 8,
|
||||
kind: Kind::Unorm,
|
||||
};
|
||||
|
||||
/// A format name of 6.7, in any case.
|
||||
pub fn parse(name: &str) -> Option<Format> {
|
||||
let name = name.trim().to_ascii_uppercase();
|
||||
let packed = |channels, bits, kind| {
|
||||
Some(Format {
|
||||
channels,
|
||||
bits,
|
||||
kind,
|
||||
})
|
||||
};
|
||||
match name.as_str() {
|
||||
"RGBA" => return Some(Self::RGBA8),
|
||||
"R3_G3_B2" => return packed(3, 3, Kind::Unorm),
|
||||
"RGB5_A1" => return packed(4, 5, Kind::Unorm),
|
||||
"RGB10_A2" => return packed(4, 10, Kind::Unorm),
|
||||
"RGB10_A2UI" => return packed(4, 10, Kind::Uint),
|
||||
"R11F_G11F_B10F" => return packed(3, 11, Kind::Float),
|
||||
"RGB9_E5" => return packed(3, 9, Kind::Float),
|
||||
"RGBA4" => return packed(4, 4, Kind::Unorm),
|
||||
"RGB565" => return packed(3, 6, Kind::Unorm),
|
||||
"RGBA2" => return packed(4, 2, Kind::Unorm),
|
||||
_ => {}
|
||||
}
|
||||
let letters = name.bytes().take_while(u8::is_ascii_alphabetic).count();
|
||||
let channels = match &name[..letters] {
|
||||
"R" => 1,
|
||||
"RG" => 2,
|
||||
"RGB" => 3,
|
||||
"RGBA" => 4,
|
||||
_ => return None,
|
||||
};
|
||||
let rest = &name[letters..];
|
||||
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
|
||||
let bits: u8 = rest[..digits].parse().ok()?;
|
||||
let kind = match &rest[digits..] {
|
||||
"" => Kind::Unorm,
|
||||
"_SNORM" => Kind::Snorm,
|
||||
"F" => Kind::Float,
|
||||
"I" => Kind::Int,
|
||||
"UI" => Kind::Uint,
|
||||
_ => return None,
|
||||
};
|
||||
let valid = match kind {
|
||||
Kind::Unorm | Kind::Snorm => matches!(bits, 8 | 16),
|
||||
Kind::Float => matches!(bits, 16 | 32),
|
||||
Kind::Int | Kind::Uint => matches!(bits, 8 | 16 | 32),
|
||||
};
|
||||
valid.then_some(Format {
|
||||
channels,
|
||||
bits,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings of one colortex buffer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Buffer {
|
||||
pub format: Format,
|
||||
/// Cleared every frame (`<buffer>Clear`, default true).
|
||||
pub clear: bool,
|
||||
/// `<buffer>ClearColor`, else 6.1.5's default: colortex0 the fog colour, colortex1 white,
|
||||
/// the others transparent black.
|
||||
pub clear_color: Option<[f32; 4]>,
|
||||
/// `<buffer>MipmapEnabled`.
|
||||
pub mipmap: bool,
|
||||
}
|
||||
|
||||
impl Default for Buffer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
format: Format::RGBA8,
|
||||
clear: true,
|
||||
clear_color: None,
|
||||
mipmap: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Config {
|
||||
pub buffers: [Buffer; BUFFERS],
|
||||
/// Conflicting or unreadable settings, as `file:line: message`.
|
||||
pub notes: Vec<String>,
|
||||
format_set: [bool; BUFFERS],
|
||||
/// Each setting read so far and its value, to report a later file changing it.
|
||||
seen: Vec<(String, String)>,
|
||||
gdepth: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
buffers: std::array::from_fn(|_| Buffer::default()),
|
||||
notes: Vec::new(),
|
||||
format_set: [false; BUFFERS],
|
||||
seen: Vec::new(),
|
||||
gdepth: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The colortex index of a buffer name or alias (6.1.1).
|
||||
pub fn buffer_index(name: &str) -> Option<usize> {
|
||||
let alias = [
|
||||
"gcolor",
|
||||
"gdepth",
|
||||
"gnormal",
|
||||
"composite",
|
||||
"gaux1",
|
||||
"gaux2",
|
||||
"gaux3",
|
||||
"gaux4",
|
||||
];
|
||||
if let Some(i) = alias.iter().position(|a| *a == name) {
|
||||
return Some(i);
|
||||
}
|
||||
let n: usize = name.strip_prefix("colortex")?.parse().ok()?;
|
||||
(n < BUFFERS).then_some(n)
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Reads one preprocessed stage file.
|
||||
pub fn read(&mut self, pp: &Preprocessed) {
|
||||
let line = |at: usize| pp.locate(code_line(&pp.code, at));
|
||||
for (at, ty, name, value) in constants(&pp.code) {
|
||||
let place = line(at);
|
||||
self.constant(&place, ty, name, value);
|
||||
}
|
||||
for (origin, comment) in &pp.comments {
|
||||
for (_, ty, name, value) in constants(comment) {
|
||||
let file = pp
|
||||
.files
|
||||
.get(origin.file as usize)
|
||||
.map_or("?", String::as_str);
|
||||
let place = format!("{file}:{}", origin.line);
|
||||
self.constant(&place, ty, name, value);
|
||||
}
|
||||
}
|
||||
if declares(&pp.code, "gdepth") {
|
||||
self.gdepth = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings once every file is read: buffer 1 becomes RGBA32F when a program declares
|
||||
/// `gdepth` and the pack gave it no format (6.1.3).
|
||||
pub fn finish(mut self) -> Self {
|
||||
if self.gdepth && !self.format_set[1] {
|
||||
self.buffers[1].format = Format {
|
||||
channels: 4,
|
||||
bits: 32,
|
||||
kind: Kind::Float,
|
||||
};
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn constant(&mut self, place: &str, ty: &str, name: &str, value: &str) {
|
||||
let settings = [
|
||||
("Format", "int"),
|
||||
("ClearColor", "vec4"),
|
||||
("Clear", "bool"),
|
||||
("MipmapEnabled", "bool"),
|
||||
];
|
||||
let Some((buffer, setting)) = settings.iter().find_map(|(suffix, want)| {
|
||||
let buffer = buffer_index(name.strip_suffix(suffix)?)?;
|
||||
(ty == *want).then_some((buffer, *suffix))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let b = &mut self.buffers[buffer];
|
||||
let ok = match setting {
|
||||
"Format" => Format::parse(value).map(|f| {
|
||||
b.format = f;
|
||||
self.format_set[buffer] = true;
|
||||
}),
|
||||
"ClearColor" => vec4(value).map(|c| b.clear_color = Some(c)),
|
||||
"Clear" => boolean(value).map(|v| b.clear = v),
|
||||
_ => boolean(value).map(|v| b.mipmap = v),
|
||||
};
|
||||
if ok.is_none() {
|
||||
let problem = match setting {
|
||||
"Format" => format!("unknown format `{value}`"),
|
||||
"ClearColor" => format!("`{name}` is not a vec4: `{value}`"),
|
||||
_ => format!("`{name}` is not a bool: `{value}`"),
|
||||
};
|
||||
self.notes.push(format!("{place}: {problem}"));
|
||||
return;
|
||||
}
|
||||
// The same setting under another name (an alias) counts as the same.
|
||||
let key = format!("{buffer}{setting}");
|
||||
match self.seen.iter_mut().find(|(k, _)| *k == key) {
|
||||
Some((_, old)) if old != value => {
|
||||
self.notes.push(format!(
|
||||
"{place}: `{name}` changed by a later file to `{value}`"
|
||||
));
|
||||
*old = value.to_owned();
|
||||
}
|
||||
Some(_) => {}
|
||||
None => self.seen.push((key, value.to_owned())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The buffers a fragment shader writes, from the last `DRAWBUFFERS` or `RENDERTARGETS` comment
|
||||
/// of its live code, or `None` without one (then it writes buffers 0 to 7). 6.1.4.
|
||||
pub fn draw_buffers(pp: &Preprocessed) -> Option<Vec<u32>> {
|
||||
let mut found = None;
|
||||
for (_, comment) in &pp.comments {
|
||||
let text = comment.trim();
|
||||
if let Some(digits) = text.strip_prefix("DRAWBUFFERS:") {
|
||||
let list: Option<Vec<u32>> = digits.trim().chars().map(|c| c.to_digit(10)).collect();
|
||||
found = list.or(found);
|
||||
} else if let Some(list) = text.strip_prefix("RENDERTARGETS:") {
|
||||
let list: Option<Vec<u32>> = list
|
||||
.split(',')
|
||||
.map(|n| {
|
||||
n.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
.filter(|&n: &u32| (n as usize) < BUFFERS)
|
||||
})
|
||||
.collect();
|
||||
found = list.or(found);
|
||||
}
|
||||
}
|
||||
found.map(|mut list| {
|
||||
list.truncate(8);
|
||||
list
|
||||
})
|
||||
}
|
||||
|
||||
/// `const <type> <name> = <value>;` anywhere in `text`: (byte offset, type, name, value).
|
||||
fn constants(text: &str) -> Vec<(usize, &str, &str, &str)> {
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0;
|
||||
while let Some(found) = text[from..].find("const") {
|
||||
let at = from + found;
|
||||
from = at + 5;
|
||||
let before = text[..at].chars().next_back();
|
||||
if before.is_some_and(|c| c.is_alphanumeric() || c == '_') {
|
||||
continue;
|
||||
}
|
||||
let rest = &text[from..];
|
||||
let Some(end) = rest.find(';') else { break };
|
||||
let decl = &rest[..end];
|
||||
let Some((left, value)) = decl.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let mut words = left.split_whitespace();
|
||||
if let (Some(ty), Some(name), None) = (words.next(), words.next(), words.next()) {
|
||||
out.push((at, ty, name, value.trim()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `text` declares a uniform sampler called `name`.
|
||||
fn declares(text: &str, name: &str) -> bool {
|
||||
text.split(';').any(|decl| {
|
||||
let words: Vec<&str> = decl.split_whitespace().collect();
|
||||
words.first() == Some(&"uniform") && words.last() == Some(&name)
|
||||
})
|
||||
}
|
||||
|
||||
fn code_line(code: &str, at: usize) -> usize {
|
||||
code[..at].matches('\n').count()
|
||||
}
|
||||
|
||||
fn boolean(value: &str) -> Option<bool> {
|
||||
match value {
|
||||
"true" => Some(true),
|
||||
"false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn number(text: &str) -> Option<f32> {
|
||||
let text = text.trim();
|
||||
text.strip_suffix(['f', 'F']).unwrap_or(text).parse().ok()
|
||||
}
|
||||
|
||||
/// `vec4(r, g, b, a)` or `vec4(x)`.
|
||||
fn vec4(value: &str) -> Option<[f32; 4]> {
|
||||
let inner = value
|
||||
.strip_prefix("vec4")?
|
||||
.trim()
|
||||
.strip_prefix('(')?
|
||||
.strip_suffix(')')?;
|
||||
let parts: Option<Vec<f32>> = inner.split(',').map(number).collect();
|
||||
match parts?.as_slice() {
|
||||
[x] => Some([*x; 4]),
|
||||
[r, g, b, a] => Some([*r, *g, *b, *a]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack::tests::zip;
|
||||
use crate::{Macros, Pack, preprocess};
|
||||
|
||||
fn stage(text: &str) -> Preprocessed {
|
||||
let pack = Pack::from_zip_bytes("t", zip(&[("shaders/composite.fsh", text)])).unwrap();
|
||||
preprocess(&pack, "composite.fsh", &Macros::standard())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_parse() {
|
||||
let f = |channels, bits, kind| {
|
||||
Some(Format {
|
||||
channels,
|
||||
bits,
|
||||
kind,
|
||||
})
|
||||
};
|
||||
assert_eq!(Format::parse("rgba16f"), f(4, 16, Kind::Float));
|
||||
assert_eq!(Format::parse("R32UI"), f(1, 32, Kind::Uint));
|
||||
assert_eq!(Format::parse("RG8_SNORM"), f(2, 8, Kind::Snorm));
|
||||
assert_eq!(Format::parse("R11F_G11F_B10F"), f(3, 11, Kind::Float));
|
||||
assert_eq!(Format::parse("RGB16"), f(3, 16, Kind::Unorm));
|
||||
assert_eq!(Format::parse("RGBA12"), None);
|
||||
assert_eq!(Format::parse("R16_SNORMX"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_buffers_and_constants_come_from_live_code() {
|
||||
let pp = stage(
|
||||
"#version 120\n\
|
||||
#define HQ\n\
|
||||
/*\n\
|
||||
const int colortex2Format = RGBA16F;\n\
|
||||
const int gaux4Format = R32F;\n\
|
||||
*/\n\
|
||||
#ifdef HQ\n\
|
||||
/* RENDERTARGETS: 0,2,11 */\n\
|
||||
#else\n\
|
||||
/* DRAWBUFFERS:01 */\n\
|
||||
const bool colortex5Clear = true;\n\
|
||||
#endif\n\
|
||||
const bool colortex5Clear = false;\n\
|
||||
const vec4 colortex3ClearColor = vec4(0.5, 0.25, 0.0, 1.0f);\n\
|
||||
const bool colortex3MipmapEnabled = true;\n\
|
||||
const vec4 gnormalClearColor = vec4(1.0);\n\
|
||||
uniform sampler2D gdepth;\n\
|
||||
void main() {}\n",
|
||||
);
|
||||
assert_eq!(draw_buffers(&pp), Some(vec![0, 2, 11]));
|
||||
let mut config = Config::default();
|
||||
config.read(&pp);
|
||||
let config = config.finish();
|
||||
let b = &config.buffers;
|
||||
assert_eq!(b[2].format, Format::parse("RGBA16F").unwrap());
|
||||
assert_eq!(b[7].format, Format::parse("R32F").unwrap());
|
||||
assert!(!b[5].clear && b[0].clear);
|
||||
assert_eq!(b[3].clear_color, Some([0.5, 0.25, 0.0, 1.0]));
|
||||
assert!(b[3].mipmap && !b[2].mipmap);
|
||||
assert_eq!(b[2].clear_color, Some([1.0; 4]));
|
||||
assert_eq!(b[1].format, Format::parse("RGBA32F").unwrap(), "gdepth");
|
||||
assert!(config.notes.is_empty(), "{:?}", config.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_directive_and_conflicts() {
|
||||
let pp = stage("void main() {}\n");
|
||||
assert_eq!(draw_buffers(&pp), None);
|
||||
let a =
|
||||
stage("/* const int colortex1Format = RGB8; */\nconst bool colortex1Clear = maybe;\n");
|
||||
let b =
|
||||
stage("/* const int colortex1Format = RGBA16; const int colortex2Format = XYZ; */\n");
|
||||
let mut config = Config::default();
|
||||
config.read(&a);
|
||||
config.read(&b);
|
||||
let notes = config.notes.join("\n");
|
||||
assert!(
|
||||
notes.contains("composite.fsh:2: `colortex1Clear` is not a bool"),
|
||||
"{notes}"
|
||||
);
|
||||
assert!(
|
||||
notes.contains("composite.fsh:1: `colortex1Format` changed"),
|
||||
"{notes}"
|
||||
);
|
||||
assert!(notes.contains("unknown format `XYZ`"), "{notes}");
|
||||
assert_eq!(
|
||||
config.finish().buffers[1].format,
|
||||
Format::parse("RGBA16").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,35 @@
|
||||
//!
|
||||
//! - [`Pack`]: a pack as a folder or a zip, with paths relative to its `shaders` folder (spec 1.1).
|
||||
//! - [`discover`]: the packs in a `shaderpacks` folder.
|
||||
//! - [`Properties`]: `shaders.properties` and the id files, with their conditional directives (3.1, 1.5).
|
||||
//! - [`Lang`]: the pack's translated names (8.4).
|
||||
//! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5).
|
||||
//! - [`translate_composite`], [`translate_gbuffers`]: a program into naga modules and its interface (1.6, 2.2).
|
||||
//! - [`Config`] and [`draw_buffers`]: the buffer settings and output lists stage files carry (1.7, 6.1).
|
||||
//! - [`load_post`]: a dimension's `prepare`, `deferred`, `composite` and `final` programs, in order (2.1).
|
||||
//! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5).
|
||||
|
||||
mod condition;
|
||||
mod config;
|
||||
mod discover;
|
||||
mod lang;
|
||||
mod macros;
|
||||
mod pack;
|
||||
mod post;
|
||||
mod preprocess;
|
||||
mod properties;
|
||||
mod translate;
|
||||
|
||||
pub use condition::eval_condition;
|
||||
pub use config::{BUFFERS, Buffer, Config, Format, Kind, buffer_index, draw_buffers};
|
||||
pub use discover::{Found, discover};
|
||||
pub use lang::Lang;
|
||||
pub use macros::{MC_VERSION, Macros, RENDER_STAGES};
|
||||
pub use pack::{Pack, PackError};
|
||||
pub use post::{Group, Post, PostProgram, dimension_folder, load_post};
|
||||
pub use preprocess::{Diagnostic, Origin, Preprocessed, preprocess};
|
||||
pub use properties::{Entry, Note, Properties};
|
||||
pub use translate::{
|
||||
Error, GbuffersInputs, Interface, Program, Sampler, Target, Uniform, translate_composite,
|
||||
translate_gbuffers,
|
||||
};
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Object-like macros: the ones packs test in `#if` lines and use inside property values
|
||||
//! (spec 1.5, 1.5.1). The GLSL preprocessor's function-like macros are not needed here.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// `MC_VERSION` of the version Horizon plays: 26.3 → 260300.
|
||||
pub const MC_VERSION: u32 = mc_version(horizon_core::version::VERSION_ID);
|
||||
|
||||
/// The documented rule, `major * 10000 + minor * 100 + patch` (spec 1.5.1, OQ-1): `1.21` → 12100,
|
||||
/// `1.7.10` → 10710. Parts that are not digits count as 0.
|
||||
pub const fn mc_version(id: &str) -> u32 {
|
||||
let bytes = id.as_bytes();
|
||||
let (mut parts, mut part, mut i) = ([0u32; 3], 0, 0);
|
||||
while i < bytes.len() && part < 3 {
|
||||
match bytes[i] {
|
||||
b'.' => part += 1,
|
||||
b @ b'0'..=b'9' => parts[part] = parts[part] * 10 + (b - b'0') as u32,
|
||||
_ => break,
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
parts[0] * 10000 + parts[1] * 100 + parts[2]
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Macro {
|
||||
/// `None` for an object-like macro; the parameter names of a function-like one.
|
||||
params: Option<Vec<String>>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Macros(BTreeMap<String, Macro>);
|
||||
|
||||
/// `MC_RENDER_STAGE_*` in the order the stages draw (spec 5.4); each is its index (OQ-7).
|
||||
pub const RENDER_STAGES: [&str; 24] = [
|
||||
"NONE",
|
||||
"SKY",
|
||||
"SUNSET",
|
||||
"CUSTOM_SKY",
|
||||
"SUN",
|
||||
"MOON",
|
||||
"STARS",
|
||||
"VOID",
|
||||
"TERRAIN_SOLID",
|
||||
"TERRAIN_CUTOUT_MIPPED",
|
||||
"TERRAIN_CUTOUT",
|
||||
"ENTITIES",
|
||||
"BLOCK_ENTITIES",
|
||||
"DESTROY",
|
||||
"OUTLINE",
|
||||
"DEBUG",
|
||||
"HAND_SOLID",
|
||||
"TERRAIN_TRANSLUCENT",
|
||||
"TRIPWIRE",
|
||||
"PARTICLES",
|
||||
"CLOUDS",
|
||||
"RAIN_SNOW",
|
||||
"WORLD_BORDER",
|
||||
"HAND_TRANSLUCENT",
|
||||
];
|
||||
|
||||
impl Macros {
|
||||
/// The standard macros that need no renderer (spec 1.5.1): `MC_VERSION`, this system's
|
||||
/// `MC_OS_*`, the GL and GLSL versions Horizon's translation accepts, the render stages and
|
||||
/// the host settings at their defaults. The GPU vendor and family arrive with the renderer.
|
||||
pub fn standard() -> Self {
|
||||
let mut m = Self::default();
|
||||
m.define("MC_VERSION", MC_VERSION.to_string());
|
||||
let os = match std::env::consts::OS {
|
||||
"windows" => "MC_OS_WINDOWS",
|
||||
"macos" => "MC_OS_MAC",
|
||||
"linux" => "MC_OS_LINUX",
|
||||
_ => "MC_OS_OTHER",
|
||||
};
|
||||
m.define(os, "");
|
||||
m.define("MC_GL_VERSION", "460");
|
||||
m.define("MC_GLSL_VERSION", "460");
|
||||
for (i, stage) in RENDER_STAGES.iter().enumerate() {
|
||||
m.define(format!("MC_RENDER_STAGE_{stage}"), i.to_string());
|
||||
}
|
||||
m.define("MC_RENDER_QUALITY", "1.0");
|
||||
m.define("MC_SHADOW_QUALITY", "1.0");
|
||||
m.define("MC_HAND_DEPTH", "0.125");
|
||||
m.define("MAX_COLOR_BUFFERS", "16");
|
||||
m
|
||||
}
|
||||
|
||||
pub fn define(&mut self, name: impl Into<String>, value: impl Into<String>) {
|
||||
let body = value.into();
|
||||
self.0.insert(name.into(), Macro { params: None, body });
|
||||
}
|
||||
|
||||
pub fn define_function(&mut self, name: &str, params: Vec<String>, body: &str) {
|
||||
let body = body.to_owned();
|
||||
self.0.insert(
|
||||
name.to_owned(),
|
||||
Macro {
|
||||
params: Some(params),
|
||||
body,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn undef(&mut self, name: &str) {
|
||||
self.0.remove(name);
|
||||
}
|
||||
|
||||
pub fn is_defined(&self, name: &str) -> bool {
|
||||
self.0.contains_key(name)
|
||||
}
|
||||
|
||||
/// The value of an object-like macro.
|
||||
pub fn get(&self, name: &str) -> Option<&str> {
|
||||
self.0
|
||||
.get(name)
|
||||
.filter(|m| m.params.is_none())
|
||||
.map(|m| m.body.as_str())
|
||||
}
|
||||
|
||||
/// `text` with every macro expanded, repeatedly; a macro is not expanded again inside its own
|
||||
/// expansion, so `A` defined as `A + 1` stops. Function-like macros take their arguments
|
||||
/// (expanded first, raw beside `##`, which pastes) when a `(` follows their name.
|
||||
pub fn expand(&self, text: &str) -> String {
|
||||
self.expand_hiding(text, &mut Vec::new())
|
||||
}
|
||||
|
||||
fn expand_hiding<'a>(&'a self, text: &str, hidden: &mut Vec<&'a str>) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let tokens = lex(text);
|
||||
let mut i = 0;
|
||||
while i < tokens.len() {
|
||||
let word = tokens[i];
|
||||
i += 1;
|
||||
let Some((name, m)) = self.0.get_key_value(word) else {
|
||||
out.push_str(word);
|
||||
continue;
|
||||
};
|
||||
if hidden.contains(&name.as_str()) {
|
||||
out.push_str(word);
|
||||
continue;
|
||||
}
|
||||
let body = match &m.params {
|
||||
None => m.body.clone(),
|
||||
Some(params) => {
|
||||
let Some((args, next)) = arguments(&tokens, i) else {
|
||||
out.push_str(word);
|
||||
continue;
|
||||
};
|
||||
i = next;
|
||||
self.substitute(params, &args, &m.body, hidden)
|
||||
}
|
||||
};
|
||||
hidden.push(name);
|
||||
out.push_str(&self.expand_hiding(&body, hidden));
|
||||
hidden.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A function-like macro's body with its parameters replaced.
|
||||
fn substitute<'a>(
|
||||
&'a self,
|
||||
params: &[String],
|
||||
args: &[String],
|
||||
body: &str,
|
||||
hidden: &mut Vec<&'a str>,
|
||||
) -> String {
|
||||
let tokens = lex(body);
|
||||
let pasted = |at: usize, step: isize| {
|
||||
let mut j = at as isize + step;
|
||||
while (0..tokens.len() as isize).contains(&j) && tokens[j as usize].trim().is_empty() {
|
||||
j += step;
|
||||
}
|
||||
(0..tokens.len() as isize).contains(&j) && tokens[j as usize] == "##"
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (at, token) in tokens.iter().enumerate() {
|
||||
match params.iter().position(|p| p == token) {
|
||||
Some(k) => {
|
||||
let arg = args.get(k).map_or("", |a| a.trim());
|
||||
if pasted(at, -1) || pasted(at, 1) {
|
||||
out.push_str(arg);
|
||||
} else {
|
||||
out.push_str(&self.expand_hiding(arg, hidden));
|
||||
}
|
||||
}
|
||||
None => out.push_str(token),
|
||||
}
|
||||
}
|
||||
// `a ## b` → `ab`.
|
||||
let mut joined = String::with_capacity(out.len());
|
||||
for (k, part) in out.split("##").enumerate() {
|
||||
joined.push_str(if k == 0 { part.trim_end() } else { part.trim() });
|
||||
}
|
||||
if out.contains("##") { joined } else { out }
|
||||
}
|
||||
}
|
||||
|
||||
/// The arguments of a call whose `(` is the next non-blank token from `i`, split at top-level
|
||||
/// commas, and the index after its `)`. `None` when no `(` follows or it never closes.
|
||||
fn arguments(tokens: &[&str], mut i: usize) -> Option<(Vec<String>, usize)> {
|
||||
while tokens.get(i)?.trim().is_empty() {
|
||||
i += 1;
|
||||
}
|
||||
if tokens[i] != "(" {
|
||||
return None;
|
||||
}
|
||||
let (mut depth, mut args, mut from) = (0, Vec::new(), i + 1);
|
||||
for (j, t) in tokens.iter().enumerate().skip(i + 1) {
|
||||
match *t {
|
||||
"(" => depth += 1,
|
||||
")" if depth == 0 => {
|
||||
args.push(tokens[from..j].concat());
|
||||
return Some((args, j + 1));
|
||||
}
|
||||
")" => depth -= 1,
|
||||
"," if depth == 0 => {
|
||||
args.push(tokens[from..j].concat());
|
||||
from = j + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Preprocessing tokens: identifiers, numbers (with their suffixes and exponents), runs of
|
||||
/// white space, `##`, and single characters. Concatenated they give back `text`.
|
||||
pub(crate) fn lex(text: &str) -> Vec<&str> {
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
let c = bytes[i] as char;
|
||||
let start = i;
|
||||
i += 1;
|
||||
if is_ident_start(c) {
|
||||
while i < bytes.len() && is_ident(bytes[i] as char) {
|
||||
i += 1;
|
||||
}
|
||||
} else if c.is_ascii_digit() || (c == '.' && bytes.get(i).is_some_and(u8::is_ascii_digit)) {
|
||||
while i < bytes.len() {
|
||||
let d = bytes[i] as char;
|
||||
let exp_sign = (d == '+' || d == '-') && matches!(bytes[i - 1], b'e' | b'E');
|
||||
if !(is_ident(d) || d == '.' || exp_sign) {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
} else if c.is_whitespace() {
|
||||
while i < bytes.len() && (bytes[i] as char).is_whitespace() {
|
||||
i += 1;
|
||||
}
|
||||
} else if c == '#' && bytes.get(i) == Some(&b'#') {
|
||||
i += 1;
|
||||
} else {
|
||||
while !text.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out.push(&text[start..i]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn is_ident_start(c: char) -> bool {
|
||||
c.is_ascii_alphabetic() || c == '_'
|
||||
}
|
||||
|
||||
pub(crate) fn is_ident(c: char) -> bool {
|
||||
c.is_ascii_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn versions_follow_the_documented_rule() {
|
||||
assert_eq!(mc_version("1.21"), 12100);
|
||||
assert_eq!(mc_version("1.21.0"), 12100);
|
||||
assert_eq!(mc_version("1.7.10"), 10710);
|
||||
assert_eq!(mc_version("26.3"), 260300);
|
||||
assert_eq!(MC_VERSION, 260300);
|
||||
assert_eq!(Macros::standard().get("MC_VERSION"), Some("260300"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expansion_replaces_whole_words_and_stops_on_recursion() {
|
||||
let mut m = Macros::default();
|
||||
m.define("RES", "SIZE");
|
||||
m.define("SIZE", "512");
|
||||
m.define("LOOP", "LOOP + 1");
|
||||
assert_eq!(m.expand("RES RES 0.5"), "512 512 0.5");
|
||||
assert_eq!(
|
||||
m.expand("RESOLUTION colortex4 LOOP"),
|
||||
"RESOLUTION colortex4 LOOP + 1"
|
||||
);
|
||||
m.define("e5", "x");
|
||||
assert_eq!(m.expand("1e5"), "1e5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_like_macros_take_arguments_and_paste() {
|
||||
let mut m = Macros::default();
|
||||
m.define("N", "4");
|
||||
m.define_function("SQ", vec!["x".into()], "((x) * (x))");
|
||||
m.define_function("CAT", vec!["a".into(), "b".into()], "a ## b");
|
||||
m.define_function("PI", vec![], "3.14");
|
||||
assert_eq!(m.expand("SQ(N + 1)"), "((4 + 1) * (4 + 1))");
|
||||
assert_eq!(m.expand("SQ(min(N, 2))"), "((min(4, 2)) * (min(4, 2)))");
|
||||
assert_eq!(m.expand("CAT(N, 2) CAT(col, ortex4)"), "N2 colortex4");
|
||||
assert_eq!(m.expand("PI() SQ SQ(1"), "3.14 SQ SQ(1");
|
||||
assert_eq!(m.get("SQ"), None);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ impl Pack {
|
||||
}
|
||||
|
||||
/// `a/./b/../c` → `a/c`; `None` for a path that climbs out of `shaders`.
|
||||
fn normalize(path: &str) -> Option<String> {
|
||||
pub(crate) fn normalize(path: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in path.split(['/', '\\']) {
|
||||
match part {
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
//! The post-processing programs of one dimension, ready for the renderer: `prepare`, `deferred`,
|
||||
//! `composite` and `final` with their numbered variants, in the order they run (spec 2.1), each
|
||||
//! preprocessed and translated, with the buffers it writes and the flips that follow it, and the
|
||||
//! buffer settings their files carry (6.1).
|
||||
//!
|
||||
//! Decisions (impl-plan.md):
|
||||
//! - OQ-12: a dimension's folder (`world0`, `world-1`, `world1`) replaces the base folder for that
|
||||
//! dimension when it exists; every stage file is read from it.
|
||||
//! - OQ-29: a program with a geometry stage is reported and treated as missing.
|
||||
//! - A program that fails to translate is reported and treated as missing (2.5).
|
||||
//! - Terrain draws with the first program of its fallback chain that loads (2.3).
|
||||
|
||||
use crate::config::{self, Config};
|
||||
use crate::{GbuffersInputs, Macros, Pack, Preprocessed, Program, Properties, Target};
|
||||
use crate::{eval_condition, preprocess, translate_composite, translate_gbuffers};
|
||||
|
||||
/// Which group of 2.1 a program belongs to.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Group {
|
||||
Prepare,
|
||||
Deferred,
|
||||
Composite,
|
||||
Final,
|
||||
/// A gbuffers program: the game draws its geometry with it.
|
||||
Gbuffers,
|
||||
}
|
||||
|
||||
/// `gbuffers_terrain` and its fallbacks (2.3).
|
||||
const TERRAIN: [&str; 4] = [
|
||||
"gbuffers_terrain",
|
||||
"gbuffers_textured_lit",
|
||||
"gbuffers_textured",
|
||||
"gbuffers_basic",
|
||||
];
|
||||
|
||||
const GROUPS: [(Group, &str); 4] = [
|
||||
(Group::Prepare, "prepare"),
|
||||
(Group::Deferred, "deferred"),
|
||||
(Group::Composite, "composite"),
|
||||
(Group::Final, "final"),
|
||||
];
|
||||
|
||||
pub struct PostProgram {
|
||||
/// The program's name (`composite3`).
|
||||
pub name: String,
|
||||
pub group: Group,
|
||||
pub program: Program,
|
||||
/// colortex buffer of each fragment output location (location n writes `buffers[n]`).
|
||||
pub buffers: Vec<u32>,
|
||||
/// `flip.<name>.<buffer>`: buffers forced to flip (true) or kept (false) after this program.
|
||||
pub flips: Vec<(u32, bool)>,
|
||||
}
|
||||
|
||||
pub struct Post {
|
||||
/// The folder the programs came from: `""` for `shaders` itself, else `world0`...
|
||||
pub folder: String,
|
||||
pub programs: Vec<PostProgram>,
|
||||
/// The flips of the virtual `deferred_pre` and `composite_pre` (6.1.6).
|
||||
pub deferred_pre: Vec<(u32, bool)>,
|
||||
pub composite_pre: Vec<(u32, bool)>,
|
||||
/// What draws solid and cutout terrain, when the game asked for it and one loads.
|
||||
pub terrain: Option<PostProgram>,
|
||||
pub config: Config,
|
||||
/// Programs skipped and why, as `program: message` or `file:line: message`.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The folder a dimension reads its programs from: `world<id>` when the pack has it, else the
|
||||
/// base folder `""` (1.2). `id` is the legacy number: 0 the Overworld, -1 the Nether, 1 the End.
|
||||
pub fn dimension_folder(pack: &Pack, id: i32) -> String {
|
||||
let folder = format!("world{id}");
|
||||
if pack.list("").iter().any(|f| f == &folder) || !pack.list(&folder).is_empty() {
|
||||
folder
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the post-processing programs of `folder` and, given the game's terrain vertices, the
|
||||
/// program terrain draws with.
|
||||
pub fn load_post(
|
||||
pack: &Pack,
|
||||
folder: &str,
|
||||
properties: &Properties,
|
||||
macros: &Macros,
|
||||
terrain: Option<&GbuffersInputs>,
|
||||
) -> Post {
|
||||
let mut post = Post {
|
||||
folder: folder.to_owned(),
|
||||
programs: Vec::new(),
|
||||
deferred_pre: flips(properties, "deferred_pre"),
|
||||
composite_pre: flips(properties, "composite_pre"),
|
||||
terrain: None,
|
||||
config: Config::default(),
|
||||
notes: Vec::new(),
|
||||
};
|
||||
let mut loader = Loader {
|
||||
pack,
|
||||
folder,
|
||||
properties,
|
||||
macros,
|
||||
post: &mut post,
|
||||
};
|
||||
for (group, base) in GROUPS {
|
||||
let names: Vec<String> = if group == Group::Final {
|
||||
vec![base.to_owned()]
|
||||
} else {
|
||||
std::iter::once(base.to_owned())
|
||||
.chain((1..=99).map(|n| format!("{base}{n}")))
|
||||
.collect()
|
||||
};
|
||||
let target = match group {
|
||||
Group::Final => Target::Window,
|
||||
_ => Target::Buffers,
|
||||
};
|
||||
for name in names {
|
||||
if let Some(p) = loader.load(name, group, |v, f| translate_composite(v, f, target)) {
|
||||
loader.post.programs.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(inputs) = terrain {
|
||||
loader.post.terrain = TERRAIN.iter().find_map(|name| {
|
||||
loader.load(name.to_string(), Group::Gbuffers, |v, f| {
|
||||
translate_gbuffers(v, f, Target::Buffers, inputs)
|
||||
})
|
||||
});
|
||||
}
|
||||
post.config = std::mem::take(&mut post.config).finish();
|
||||
post
|
||||
}
|
||||
|
||||
struct Loader<'a> {
|
||||
pack: &'a Pack,
|
||||
folder: &'a str,
|
||||
properties: &'a Properties,
|
||||
macros: &'a Macros,
|
||||
post: &'a mut Post,
|
||||
}
|
||||
|
||||
impl Loader<'_> {
|
||||
/// The program `name` translated by `translate`, or `None` when it is missing, disabled or
|
||||
/// fails (with a note).
|
||||
fn load(
|
||||
&mut self,
|
||||
name: String,
|
||||
group: Group,
|
||||
translate: impl FnOnce(&Preprocessed, &Preprocessed) -> Result<Program, Vec<crate::Error>>,
|
||||
) -> Option<PostProgram> {
|
||||
let path = |ext: &str| match self.folder {
|
||||
"" => format!("{name}.{ext}"),
|
||||
f => format!("{f}/{name}.{ext}"),
|
||||
};
|
||||
let (vsh, fsh) = (path("vsh"), path("fsh"));
|
||||
let notes = &mut self.post.notes;
|
||||
if !self.pack.exists(&fsh) || !self.pack.exists(&vsh) {
|
||||
return None;
|
||||
}
|
||||
if !enabled(self.properties, self.folder, &name, self.macros) {
|
||||
return None;
|
||||
}
|
||||
if self.pack.exists(&path("gsh")) {
|
||||
notes.push(format!(
|
||||
"{name}: uses a geometry stage (not supported); skipped"
|
||||
));
|
||||
return None;
|
||||
}
|
||||
let vertex = preprocess(self.pack, &vsh, self.macros);
|
||||
let fragment = preprocess(self.pack, &fsh, self.macros);
|
||||
let problems: Vec<String> = [&vertex, &fragment]
|
||||
.iter()
|
||||
.flat_map(|pp| diagnostics(pp))
|
||||
.collect();
|
||||
if !problems.is_empty() {
|
||||
notes.extend(problems);
|
||||
notes.push(format!("{name}: skipped"));
|
||||
return None;
|
||||
}
|
||||
let program = match translate(&vertex, &fragment) {
|
||||
Ok(p) => p,
|
||||
Err(errors) => {
|
||||
notes.extend(
|
||||
errors
|
||||
.iter()
|
||||
.map(|e| format!("{}:{}: {}", e.file, e.line, e.message)),
|
||||
);
|
||||
notes.push(format!("{name}: skipped"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
self.post.config.read(&vertex);
|
||||
self.post.config.read(&fragment);
|
||||
let buffers = config::draw_buffers(&fragment).unwrap_or_else(|| (0..8).collect());
|
||||
Some(PostProgram {
|
||||
flips: flips(self.properties, &name),
|
||||
name,
|
||||
group,
|
||||
program,
|
||||
buffers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostics(pp: &Preprocessed) -> Vec<String> {
|
||||
pp.diagnostics
|
||||
.iter()
|
||||
.map(|d| {
|
||||
let file = pp
|
||||
.files
|
||||
.get(d.origin.file as usize)
|
||||
.map_or("?", String::as_str);
|
||||
format!("{file}:{}: {}", d.origin.line, d.message)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `program.<name>.enabled` (or `program.<folder>/<name>.enabled`): a boolean expression over
|
||||
/// switch options (3.3). An option is on when its macro is defined.
|
||||
fn enabled(properties: &Properties, folder: &str, name: &str, macros: &Macros) -> bool {
|
||||
let keys = [
|
||||
format!("program.{folder}/{name}.enabled"),
|
||||
format!("program.{name}.enabled"),
|
||||
];
|
||||
let Some(expr) = keys.iter().find_map(|k| properties.get(k)) else {
|
||||
return true;
|
||||
};
|
||||
// `true`/`false` as numbers, every other name as `defined(name)`, then the `#if` arithmetic.
|
||||
let mut condition = String::new();
|
||||
for token in crate::macros::lex(expr) {
|
||||
match token {
|
||||
"true" => condition.push('1'),
|
||||
"false" => condition.push('0'),
|
||||
t if t.starts_with(|c: char| c.is_alphabetic() || c == '_') => {
|
||||
condition.push_str(&format!("defined({t})"));
|
||||
}
|
||||
t => condition.push_str(t),
|
||||
}
|
||||
}
|
||||
eval_condition(&condition, macros).ok() != Some(0)
|
||||
}
|
||||
|
||||
/// `flip.<program>.<buffer>=<bool>` keys of one program.
|
||||
fn flips(properties: &Properties, program: &str) -> Vec<(u32, bool)> {
|
||||
let prefix = format!("flip.{program}.");
|
||||
properties
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
let buffer = config::buffer_index(e.key.strip_prefix(&prefix)?)?;
|
||||
let value = match e.value.trim() {
|
||||
"true" => true,
|
||||
"false" => false,
|
||||
_ => return None,
|
||||
};
|
||||
Some((buffer as u32, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack::tests::zip;
|
||||
|
||||
const VSH: &str = "#version 120\nvarying vec2 uv;\nvoid main() {\n\
|
||||
gl_Position = ftransform();\nuv = gl_MultiTexCoord0.xy;\n}\n";
|
||||
const FSH: &str = "#version 120\nuniform sampler2D colortex0;\nvarying vec2 uv;\n\
|
||||
void main() {\n/* DRAWBUFFERS:3 */\n\
|
||||
gl_FragData[0] = texture2D(colortex0, uv);\n}\n";
|
||||
|
||||
#[test]
|
||||
fn programs_load_in_order_with_their_settings() {
|
||||
let pack = Pack::from_zip_bytes(
|
||||
"t",
|
||||
zip(&[
|
||||
("shaders/composite.vsh", VSH),
|
||||
("shaders/composite.fsh", FSH),
|
||||
("shaders/composite2.vsh", VSH),
|
||||
("shaders/composite2.fsh", FSH),
|
||||
("shaders/deferred.vsh", VSH),
|
||||
(
|
||||
"shaders/deferred.fsh",
|
||||
"#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n",
|
||||
),
|
||||
("shaders/composite1.fsh", FSH),
|
||||
("shaders/composite3.vsh", VSH),
|
||||
("shaders/composite3.fsh", FSH),
|
||||
("shaders/composite4.vsh", VSH),
|
||||
("shaders/composite4.fsh", FSH),
|
||||
("shaders/composite4.gsh", "x"),
|
||||
("shaders/composite5.vsh", VSH),
|
||||
(
|
||||
"shaders/composite5.fsh",
|
||||
"#version 120\nvoid main() { undefined(); }\n",
|
||||
),
|
||||
("shaders/final.vsh", VSH),
|
||||
("shaders/final.fsh", FSH),
|
||||
("shaders/world-1/final.vsh", VSH),
|
||||
("shaders/world-1/final.fsh", FSH),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let properties = Properties::parse(
|
||||
"program.composite3.enabled = HQ && !LOW\nflip.composite2.gaux1=true\n\
|
||||
flip.composite.colortex3 = false\nflip.composite_pre.colortex1=true\n",
|
||||
&Macros::default(),
|
||||
);
|
||||
assert_eq!(dimension_folder(&pack, 0), "");
|
||||
assert_eq!(dimension_folder(&pack, -1), "world-1");
|
||||
let post = load_post(&pack, "", &properties, &Macros::standard(), None);
|
||||
let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["deferred", "composite", "composite2", "final"]);
|
||||
let p = &post.programs;
|
||||
assert_eq!(p[0].buffers, (0..8).collect::<Vec<_>>());
|
||||
assert_eq!(p[1].buffers, [3]);
|
||||
assert_eq!(p[1].flips, [(3, false)]);
|
||||
assert_eq!(p[2].flips, [(4, true)]);
|
||||
assert_eq!(post.composite_pre, [(1, true)]);
|
||||
assert_eq!(p[3].group, Group::Final);
|
||||
let notes = post.notes.join("\n");
|
||||
assert!(
|
||||
notes.contains("composite4: uses a geometry stage"),
|
||||
"{notes}"
|
||||
);
|
||||
assert!(notes.contains("composite5.fsh:2:"), "{notes}");
|
||||
assert!(notes.contains("composite5: skipped"), "{notes}");
|
||||
|
||||
let mut macros = Macros::standard();
|
||||
macros.define("HQ", "");
|
||||
let post = load_post(&pack, "world-1", &properties, ¯os, None);
|
||||
let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["final"]);
|
||||
let post = load_post(&pack, "", &properties, ¯os, None);
|
||||
assert!(post.programs.iter().any(|p| p.name == "composite3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizons_test_pack_loads() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/packs/post-test");
|
||||
let pack = Pack::open(&dir).unwrap();
|
||||
let macros = Macros::standard();
|
||||
let properties = Properties::parse(&pack.read_text("shaders.properties").unwrap(), ¯os);
|
||||
let post = load_post(
|
||||
&pack,
|
||||
&dimension_folder(&pack, 0),
|
||||
&properties,
|
||||
¯os,
|
||||
None,
|
||||
);
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["composite", "composite1", "final"]);
|
||||
assert_eq!(post.programs[0].buffers, [0, 2]);
|
||||
assert_eq!(post.programs[1].buffers, [0]);
|
||||
assert_eq!(
|
||||
post.config.buffers[2].format,
|
||||
crate::Format::parse("RGBA16F").unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
//! The GLSL preprocessor a program's stage file goes through before translation (spec 1.4,
|
||||
//! 1.5, 1.6): comments removed, `#include` expanded, conditionals evaluated, macros expanded.
|
||||
//! Every output line remembers the pack file and line it came from, so errors found later point
|
||||
//! at the pack's own code.
|
||||
//!
|
||||
//! `#include` is expanded where it stands in live code only: an include inside a false `#if`
|
||||
//! is not read, so it can't fail (OQ-14). Ten levels deep at most, which also stops a file that
|
||||
//! includes itself. `#version` and `#extension` are taken out of the code and kept apart: the
|
||||
//! translation writes its own header.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::Macros;
|
||||
use crate::Pack;
|
||||
use crate::condition::{Conditionals, split_directive};
|
||||
use crate::pack::normalize;
|
||||
|
||||
const MAX_DEPTH: usize = 10;
|
||||
|
||||
/// Where a line came from: an index into [`Preprocessed::files`] and a line number from 1.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Origin {
|
||||
pub file: u16,
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Diagnostic {
|
||||
pub origin: Origin,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Preprocessed {
|
||||
/// The code, one line per entry of `lines`.
|
||||
pub code: String,
|
||||
pub lines: Vec<Origin>,
|
||||
/// The pack paths of the files read, the stage file first.
|
||||
pub files: Vec<String>,
|
||||
/// `#version` of the stage file: the number and the profile word, if any.
|
||||
pub version: Option<(u32, Option<String>)>,
|
||||
/// `#extension` lines of live code, as written after `#extension`.
|
||||
pub extensions: Vec<String>,
|
||||
/// The text of every block comment in live code (`DRAWBUFFERS:`, buffer formats).
|
||||
pub comments: Vec<(Origin, String)>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
impl Preprocessed {
|
||||
/// `file:line` of an output line (from 0), for messages.
|
||||
pub fn locate(&self, line: usize) -> String {
|
||||
let o = self.lines.get(line).copied().unwrap_or_default();
|
||||
let file = self.files.get(o.file as usize).map_or("?", String::as_str);
|
||||
format!("{file}:{}", o.line)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the preprocessor on the stage file `path` of `pack` with `macros` defined.
|
||||
pub fn preprocess(pack: &Pack, path: &str, macros: &Macros) -> Preprocessed {
|
||||
let mut run = Run {
|
||||
pack,
|
||||
macros: macros.clone(),
|
||||
conditionals: Conditionals::default(),
|
||||
out: Preprocessed::default(),
|
||||
code: Vec::new(),
|
||||
texts: HashMap::new(),
|
||||
};
|
||||
run.file(path, 0, Origin::default());
|
||||
if run.conditionals.open() {
|
||||
run.diagnose(Origin::default(), "`#if` without `#endif`");
|
||||
}
|
||||
run.out.code = run.code.join("\n");
|
||||
run.out
|
||||
}
|
||||
|
||||
struct Run<'p> {
|
||||
pack: &'p Pack,
|
||||
macros: Macros,
|
||||
conditionals: Conditionals,
|
||||
out: Preprocessed,
|
||||
code: Vec<String>,
|
||||
/// Files already read: a guarded header is included many times.
|
||||
texts: HashMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
impl Run<'_> {
|
||||
fn file(&mut self, path: &str, depth: usize, from: Origin) {
|
||||
let cached = self
|
||||
.texts
|
||||
.entry(path.to_owned())
|
||||
.or_insert_with(|| self.pack.read_text(path));
|
||||
let Some(text) = cached.clone() else {
|
||||
return self.diagnose(from, &format!("can't find `{path}`"));
|
||||
};
|
||||
let file = match self.out.files.iter().position(|f| f == path) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.out.files.push(path.to_owned());
|
||||
self.out.files.len() - 1
|
||||
}
|
||||
} as u16;
|
||||
let (text, comments) = strip_comments(&text);
|
||||
let lines: Vec<&str> = text.split('\n').map(|l| l.trim_end_matches('\r')).collect();
|
||||
let mut comments = comments.into_iter().peekable();
|
||||
let mut n = 0;
|
||||
while n < lines.len() {
|
||||
let first = n;
|
||||
let mut line = lines[n].to_owned();
|
||||
while line.ends_with('\\') && n + 1 < lines.len() {
|
||||
line.pop();
|
||||
n += 1;
|
||||
line.push_str(lines[n]);
|
||||
}
|
||||
n += 1;
|
||||
let origin = Origin {
|
||||
file,
|
||||
line: first as u32 + 1,
|
||||
};
|
||||
self.line(&line, origin, path, depth);
|
||||
for k in first + 1..n {
|
||||
self.emit(
|
||||
String::new(),
|
||||
Origin {
|
||||
file,
|
||||
line: k as u32 + 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
while let Some((_, comment)) = comments.next_if(|(at, _)| *at < n) {
|
||||
if self.conditionals.active() {
|
||||
self.out.comments.push((origin, comment));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&mut self, line: &str, origin: Origin, path: &str, depth: usize) {
|
||||
let Some(after) = line.trim_start().strip_prefix('#') else {
|
||||
let code = if self.conditionals.active() {
|
||||
self.macros.define("__LINE__", origin.line.to_string());
|
||||
self.macros.expand(line)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
return self.emit(code, origin);
|
||||
};
|
||||
self.emit(String::new(), origin);
|
||||
let Some((word, rest)) = split_directive(after) else {
|
||||
return self.diagnose(origin, &format!("unknown directive `#{}`", after.trim()));
|
||||
};
|
||||
match self.conditionals.apply(word, rest, &self.macros) {
|
||||
Err(e) => return self.diagnose(origin, &e),
|
||||
Ok(true) => return,
|
||||
Ok(false) if !self.conditionals.active() => return,
|
||||
Ok(false) => {}
|
||||
}
|
||||
match word {
|
||||
"define" => self.define(rest, origin),
|
||||
"undef" => self.macros.undef(rest),
|
||||
"include" => {
|
||||
let target = rest.trim_matches(|c| c == '"' || c == '<' || c == '>');
|
||||
match resolve(path, target) {
|
||||
_ if depth + 1 >= MAX_DEPTH => self.diagnose(
|
||||
origin,
|
||||
&format!("`{target}`: includes nest more than {MAX_DEPTH} deep"),
|
||||
),
|
||||
Some(file) => {
|
||||
// The include line's own (empty) output line stays; the file follows.
|
||||
self.file(&file, depth + 1, origin);
|
||||
}
|
||||
None => self.diagnose(origin, &format!("`{target}` is outside the pack")),
|
||||
}
|
||||
}
|
||||
"version" if depth == 0 && self.out.version.is_none() => {
|
||||
let mut words = rest.split_whitespace();
|
||||
let number = words.next().and_then(|v| v.parse().ok()).unwrap_or(110);
|
||||
self.out.version = Some((number, words.next().map(str::to_owned)));
|
||||
self.macros.define("__VERSION__", number.to_string());
|
||||
}
|
||||
"extension" => self.out.extensions.push(rest.to_owned()),
|
||||
"error" => self.diagnose(origin, &format!("#error {rest}")),
|
||||
"version" | "pragma" | "line" | "" => {}
|
||||
_ => self.diagnose(origin, &format!("unknown directive `#{word}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `#define NAME body` or `#define NAME(a, b) body` (no space before the `(`).
|
||||
fn define(&mut self, rest: &str, origin: Origin) {
|
||||
let len = rest
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.unwrap_or(rest.len());
|
||||
let (name, tail) = rest.split_at(len);
|
||||
if name.is_empty() {
|
||||
return self.diagnose(origin, "`#define` without a name");
|
||||
}
|
||||
if let Some(params) = tail.strip_prefix('(') {
|
||||
let Some((params, body)) = params.split_once(')') else {
|
||||
return self.diagnose(origin, &format!("`#define {name}(` without `)`"));
|
||||
};
|
||||
let params = params
|
||||
.split(',')
|
||||
.map(|p| p.trim().to_owned())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
self.macros.define_function(name, params, body.trim());
|
||||
} else {
|
||||
self.macros.define(name, tail.trim());
|
||||
}
|
||||
}
|
||||
|
||||
fn emit(&mut self, code: String, origin: Origin) {
|
||||
self.code.push(code);
|
||||
self.out.lines.push(origin);
|
||||
}
|
||||
|
||||
fn diagnose(&mut self, origin: Origin, message: &str) {
|
||||
self.out.diagnostics.push(Diagnostic {
|
||||
origin,
|
||||
message: message.to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// An `#include` path: from the `shaders` folder with a leading `/`, else from the including
|
||||
/// file's folder (spec 1.4). `None` when it climbs out of the pack.
|
||||
fn resolve(from_file: &str, target: &str) -> Option<String> {
|
||||
match target.strip_prefix('/') {
|
||||
Some(absolute) => normalize(absolute),
|
||||
None => {
|
||||
let dir = from_file.rsplit_once('/').map_or("", |(dir, _)| dir);
|
||||
normalize(&format!("{dir}/{target}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with comments replaced by spaces (new lines kept, so line numbers hold), and the body of
|
||||
/// each block comment with the index of the line it starts on.
|
||||
fn strip_comments(text: &str) -> (String, Vec<(usize, String)>) {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut comments = Vec::new();
|
||||
let (mut rest, mut line) = (text, 0);
|
||||
while let Some(at) = rest.find('/') {
|
||||
let (before, from) = rest.split_at(at);
|
||||
out.push_str(before);
|
||||
line += before.matches('\n').count();
|
||||
if let Some(body) = from.strip_prefix("//") {
|
||||
let end = body.find('\n').unwrap_or(body.len());
|
||||
rest = &body[end..];
|
||||
} else if let Some(body) = from.strip_prefix("/*") {
|
||||
let end = body.find("*/").unwrap_or(body.len());
|
||||
let comment = &body[..end];
|
||||
comments.push((line, comment.to_owned()));
|
||||
let newlines = comment.matches('\n').count();
|
||||
line += newlines;
|
||||
out.push(' ');
|
||||
out.extend(std::iter::repeat_n('\n', newlines));
|
||||
rest = body.get(end + 2..).unwrap_or("");
|
||||
} else {
|
||||
out.push('/');
|
||||
rest = &from[1..];
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
(out, comments)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pack::tests::zip;
|
||||
|
||||
fn run(files: &[(&str, &str)]) -> Preprocessed {
|
||||
let files: Vec<(String, &str)> = files
|
||||
.iter()
|
||||
.map(|(p, t)| (format!("shaders/{p}"), *t))
|
||||
.collect();
|
||||
let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect();
|
||||
let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap();
|
||||
preprocess(&pack, "program/final.fsh", &Macros::standard())
|
||||
}
|
||||
|
||||
fn code_lines(p: &Preprocessed) -> Vec<(&str, String)> {
|
||||
p.code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.trim().is_empty())
|
||||
.map(|(i, l)| (l.trim(), p.locate(i)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_resolve_and_lines_map_back() {
|
||||
let p = run(&[
|
||||
(
|
||||
"program/final.fsh",
|
||||
"#version 330 compatibility\n#include \"/lib/a.glsl\"\nvoid main() {}\n",
|
||||
),
|
||||
(
|
||||
"lib/a.glsl",
|
||||
"#ifndef A\n#define A\n#include \"b.glsl\"\nfloat a;\n#endif\n",
|
||||
),
|
||||
("lib/b.glsl", "// b\nfloat b;\n#include \"a.glsl\"\n"),
|
||||
]);
|
||||
assert_eq!(p.version, Some((330, Some("compatibility".into()))));
|
||||
assert_eq!(
|
||||
code_lines(&p),
|
||||
[
|
||||
("float b;", "lib/b.glsl:2".into()),
|
||||
("float a;", "lib/a.glsl:4".into()),
|
||||
("void main() {}", "program/final.fsh:3".into()),
|
||||
]
|
||||
);
|
||||
assert!(p.diagnostics.is_empty(), "{:?}", p.diagnostics);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_runaway_includes_are_diagnosed_where_they_stand() {
|
||||
let p = run(&[(
|
||||
"program/final.fsh",
|
||||
"#ifdef NOPE\n#include \"gone.glsl\"\n#endif\n#include \"gone.glsl\"\n#include \"final.fsh\"\n",
|
||||
)]);
|
||||
let messages: Vec<String> = p
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|d| format!("{}: {}", d.origin.line, d.message))
|
||||
.collect();
|
||||
assert_eq!(messages[0], "4: can't find `program/gone.glsl`");
|
||||
assert!(
|
||||
messages
|
||||
.last()
|
||||
.unwrap()
|
||||
.ends_with("includes nest more than 10 deep")
|
||||
);
|
||||
assert!(
|
||||
run(&[("program/final.fsh", "#include \"../../x\"")]).diagnostics[0]
|
||||
.message
|
||||
.contains("outside")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_expand_like_the_glsl_preprocessor() {
|
||||
let p = run(&[(
|
||||
"program/final.fsh",
|
||||
"#define SQ(x) ((x) * (x))\n#define CAT(a, b) a ## b\n#define N 4\n#define \\\n LONG 1\n\
|
||||
float v = SQ(N + 1) + CAT(N, 2) + LONG;\nint l = __LINE__;\n#if MC_VERSION > 12100 && defined MC_RENDER_STAGE_SKY\nint ok;\n#else\nint bad;\n#endif\n",
|
||||
)]);
|
||||
let lines: Vec<&str> = code_lines(&p).into_iter().map(|(l, _)| l).collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
"float v = ((4 + 1) * (4 + 1)) + N2 + 1;",
|
||||
"int l = 7;",
|
||||
"int ok;"
|
||||
]
|
||||
);
|
||||
assert_eq!(p.lines.len(), p.code.split('\n').count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_extensions_and_errors() {
|
||||
let p = run(&[(
|
||||
"program/final.fsh",
|
||||
"#version 120\n#extension GL_ARB_shader_texture_lod : enable\n/* DRAWBUFFERS:04 */\n#if 0\n/* RENDERTARGETS: 1 */\n#error dead\n#endif\n\
|
||||
/* multi\nline */ float x; // gone\n#error live\n#bogus\n",
|
||||
)]);
|
||||
assert_eq!(p.extensions, ["GL_ARB_shader_texture_lod : enable"]);
|
||||
let comments: Vec<&str> = p.comments.iter().map(|(_, c)| c.trim()).collect();
|
||||
assert_eq!(comments, ["DRAWBUFFERS:04", "multi\nline"]);
|
||||
assert_eq!(code_lines(&p), [("float x;", "program/final.fsh:9".into())]);
|
||||
let messages: Vec<&str> = p.diagnostics.iter().map(|d| d.message.as_str()).collect();
|
||||
assert_eq!(messages, ["#error live", "unknown directive `#bogus`"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! `.properties` files: `shaders.properties` (spec 3.1) and the id files `block.properties`,
|
||||
//! `item.properties`, `entity.properties` (4.1). `key=value` lines, `#` comments, a trailing `\`
|
||||
//! continuing a value on the next line, and conditional directives evaluated with the
|
||||
//! standard and option macros (1.5 item 2). `#define` and `#undef` work in every one of them:
|
||||
//! a public pack defines a macro in its `shaders.properties` and tests it further down.
|
||||
//! No other escape is read: values are names, numbers and expressions. A later duplicate key
|
||||
//! replaces the earlier one, with a note.
|
||||
|
||||
use crate::Macros;
|
||||
use crate::condition::{Conditionals, split_directive};
|
||||
|
||||
/// One `key=value`, with the line it starts on (from 1).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Entry {
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub line: u32,
|
||||
}
|
||||
|
||||
/// Something wrong with a line, for the pack's log.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Note {
|
||||
pub line: u32,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Properties {
|
||||
/// In file order; a key appears once (a later duplicate replaces the earlier one).
|
||||
pub entries: Vec<Entry>,
|
||||
pub notes: Vec<Note>,
|
||||
}
|
||||
|
||||
impl Properties {
|
||||
/// Reads `text`; `#define`/`#undef` change the macros for the lines after them only.
|
||||
pub fn parse(text: &str, macros: &Macros) -> Self {
|
||||
let mut out = Self::default();
|
||||
let mut macros = macros.clone();
|
||||
let mut conditionals = Conditionals::default();
|
||||
let mut lines = text.lines().zip(1u32..);
|
||||
while let Some((raw, number)) = lines.next() {
|
||||
let line = raw.trim();
|
||||
let on = conditionals.active();
|
||||
if let Some(directive) = line.strip_prefix('#') {
|
||||
if let Some((word, rest)) = split_directive(directive) {
|
||||
out.directive(word, rest, number, &mut conditionals, &mut macros);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Continuation lines belong to their entry even inside a skipped branch.
|
||||
let mut logical = line.to_owned();
|
||||
while ends_with_continuation(&logical) {
|
||||
logical.pop();
|
||||
match lines.next() {
|
||||
Some((next, _)) => logical.push_str(next.trim()),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if !on || logical.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match logical.split_once('=') {
|
||||
Some((key, value)) if !key.trim().is_empty() => out.insert(Entry {
|
||||
key: key.trim().to_owned(),
|
||||
value: value.trim().to_owned(),
|
||||
line: number,
|
||||
}),
|
||||
_ => out.note(number, "not a key=value line"),
|
||||
}
|
||||
}
|
||||
if conditionals.open() {
|
||||
out.note(text.lines().count() as u32, "`#if` without `#endif`");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.entry(key).map(|e| e.value.as_str())
|
||||
}
|
||||
|
||||
pub fn entry(&self, key: &str) -> Option<&Entry> {
|
||||
self.entries.iter().find(|e| e.key == key)
|
||||
}
|
||||
|
||||
fn insert(&mut self, entry: Entry) {
|
||||
if let Some(at) = self.entries.iter().position(|e| e.key == entry.key) {
|
||||
let first = self.entries.remove(at).line;
|
||||
self.note(
|
||||
entry.line,
|
||||
&format!(
|
||||
"`{}` is set again (first on line {first}); this one counts",
|
||||
entry.key
|
||||
),
|
||||
);
|
||||
}
|
||||
self.entries.push(entry);
|
||||
}
|
||||
|
||||
fn note(&mut self, line: u32, message: &str) {
|
||||
self.notes.push(Note {
|
||||
line,
|
||||
message: message.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
/// A line starting with `#` whose first word may be a directive; any other word makes it a
|
||||
/// comment.
|
||||
fn directive(
|
||||
&mut self,
|
||||
word: &str,
|
||||
rest: &str,
|
||||
number: u32,
|
||||
conditionals: &mut Conditionals,
|
||||
macros: &mut Macros,
|
||||
) {
|
||||
match conditionals.apply(word, rest, macros) {
|
||||
Err(e) => self.note(number, &e),
|
||||
Ok(true) => {}
|
||||
Ok(false) if conditionals.active() => match word {
|
||||
"define" => {
|
||||
let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||
macros.define(name, value.trim());
|
||||
}
|
||||
"undef" => macros.undef(rest),
|
||||
_ => {}
|
||||
},
|
||||
Ok(false) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An odd number of trailing backslashes: the last one joins the next line (`\\` is a literal).
|
||||
fn ends_with_continuation(line: &str) -> bool {
|
||||
line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn read(text: &str) -> Properties {
|
||||
let mut m = Macros::standard();
|
||||
m.define("SHADOWS", "");
|
||||
m.define("QUALITY", "2");
|
||||
Properties::parse(text, &m)
|
||||
}
|
||||
|
||||
fn pairs(p: &Properties) -> Vec<(&str, &str)> {
|
||||
p.entries
|
||||
.iter()
|
||||
.map(|e| (e.key.as_str(), e.value.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_values_and_comments() {
|
||||
let p =
|
||||
read("# a comment\n\n sun = false\n#commented.key=1\nscreen=A B [C]\r\nbad line\n");
|
||||
assert_eq!(pairs(&p), [("sun", "false"), ("screen", "A B [C]")]);
|
||||
assert_eq!(p.entry("screen").unwrap().line, 5);
|
||||
assert_eq!(
|
||||
p.notes,
|
||||
[Note {
|
||||
line: 6,
|
||||
message: "not a key=value line".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_lines_join() {
|
||||
let p = read("screen=A B \\\n C D\\\n E\nsliders=X\nliteral=a\\\\\nnext=1");
|
||||
assert_eq!(
|
||||
pairs(&p),
|
||||
[
|
||||
("screen", "A B C DE"),
|
||||
("sliders", "X"),
|
||||
("literal", "a\\\\"),
|
||||
("next", "1")
|
||||
]
|
||||
);
|
||||
assert_eq!(p.entry("sliders").unwrap().line, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditionals_pick_one_branch() {
|
||||
let p = read(
|
||||
"#if MC_VERSION >= 11300\nnew=1\n#else\nold=1\n#endif\n\
|
||||
#ifdef SHADOWS\n#if QUALITY == 1\nq=1\n#elif QUALITY == 2\nq=2\n#elif QUALITY >= 2\nq=3\n#endif\n#endif\n\
|
||||
#ifndef SHADOWS\nnone=1\n#elif 0\nnever=1\n#else\nfallback=1\n#endif\n# ifdef SHADOWS // trailing comment\nspaced=1\n#endif",
|
||||
);
|
||||
assert_eq!(
|
||||
pairs(&p),
|
||||
[("new", "1"), ("q", "2"), ("fallback", "1"), ("spaced", "1")]
|
||||
);
|
||||
assert!(p.notes.is_empty(), "{:?}", p.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_lines_are_not_directives() {
|
||||
let p = read("#ifdefault=1\n#iffy\n#endiff\n#else-where\nkey=1");
|
||||
assert_eq!(pairs(&p), [("key", "1")]);
|
||||
assert!(p.notes.is_empty(), "{:?}", p.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defines_apply_to_the_lines_after_them() {
|
||||
let text = "#ifdef MC_OS_NONE\n#define WATER 0\n#endif\n#define WATER 8\n\
|
||||
#if WATER == 8\nblock.8=minecraft:water\n#endif\n#undef WATER\n#ifdef WATER\nno=1\n#endif";
|
||||
let p = Properties::parse(text, &Macros::default());
|
||||
assert_eq!(pairs(&p), [("block.8", "minecraft:water")]);
|
||||
assert!(p.notes.is_empty(), "{:?}", p.notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keys_keep_the_last() {
|
||||
let p = read("a=1\nb=2\na=3");
|
||||
assert_eq!(pairs(&p), [("b", "2"), ("a", "3")]);
|
||||
assert_eq!(p.notes[0].line, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_conditionals_are_noted() {
|
||||
let p = read("#endif\n#else\n#if 1 +\nx=1\n#endif\n#if 1\n#else\n#else\n");
|
||||
let lines: Vec<u32> = p.notes.iter().map(|n| n.line).collect();
|
||||
assert_eq!(lines, [1, 2, 3, 8, 8]);
|
||||
assert!(p.entries.is_empty());
|
||||
}
|
||||
}
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user