mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Compare commits
243
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec9c65be87 | ||
|
|
a84f637dd6 | ||
|
|
efab4d1bbf | ||
|
|
7b50bae250 | ||
|
|
3780c215a7 | ||
|
|
3c853b46eb | ||
|
|
e64af82d00 | ||
|
|
37bf47f477 | ||
|
|
03d8150bdb | ||
|
|
c89cd3f9e1 | ||
|
|
0b8d44458a | ||
|
|
75a016b1b0 | ||
|
|
691a39cab6 | ||
|
|
3c06105a0e | ||
|
|
726726a1fc | ||
|
|
82da4379bd | ||
|
|
7627e37dba | ||
|
|
cd8eb96cef | ||
|
|
829aec1dba | ||
|
|
82a6e896d1 | ||
|
|
c817ed7fff | ||
|
|
b020ab26b9 | ||
|
|
0300ba0a76 | ||
|
|
3a3c182aed | ||
|
|
35f095bd41 | ||
|
|
e76394c2cf | ||
|
|
d9cfca426c | ||
|
|
763d4868d7 | ||
|
|
110a92a1aa | ||
|
|
3a8d647c8c | ||
|
|
3c346ba1a4 | ||
|
|
b41db51bf9 | ||
|
|
498d9bebcf | ||
|
|
1ecc5a9bdc | ||
|
|
d05675d755 | ||
|
|
2568835cc6 | ||
|
|
128575e4e5 | ||
|
|
cb870cf80d | ||
|
|
5385996153 | ||
|
|
f0cb1e42c0 | ||
|
|
38174db59b | ||
|
|
093f5efda8 | ||
|
|
cdfbac97bd | ||
|
|
b444a6b64f | ||
|
|
e8d192f2ac | ||
|
|
6ad39aa412 | ||
|
|
6342ce83b2 | ||
|
|
f4e5fbdfd0 | ||
|
|
6d5b520591 | ||
|
|
dff332b257 | ||
|
|
c1dc47e41f | ||
|
|
7311870047 | ||
|
|
d237f961f8 | ||
|
|
595421d8ee | ||
|
|
7169886142 | ||
|
|
a8bf4c1474 | ||
|
|
cdcac3463e | ||
|
|
e71c626484 | ||
|
|
194ede9376 | ||
|
|
12578352dc | ||
|
|
11ebc3f199 | ||
|
|
29ac1e17b9 | ||
|
|
115f5235b9 | ||
|
|
0fd4e22139 | ||
|
|
ec19b6320a | ||
|
|
16e29a2774 | ||
|
|
e2afd13715 | ||
|
|
00aba43d81 | ||
|
|
e893fd3eac | ||
|
|
ee986c3e62 | ||
|
|
19aabc08c2 | ||
|
|
d016678e24 | ||
|
|
ca3649534d | ||
|
|
984b7d8635 | ||
|
|
4881a0862f | ||
|
|
8df100e589 | ||
|
|
a416f1dfcd | ||
|
|
4c52129f7d | ||
|
|
afa60454b2 | ||
|
|
243d765452 | ||
|
|
ff52e244ae | ||
|
|
bb712cca1a | ||
|
|
1498fdecd3 | ||
|
|
8cf1e6b4d6 | ||
|
|
341a92902c | ||
|
|
aa8592fbf9 | ||
|
|
e859476293 | ||
|
|
89235891ce | ||
|
|
2081a0dd95 | ||
|
|
cef35e64e7 | ||
|
|
3a9b05d21d | ||
|
|
3f1a50ccb2 | ||
|
|
8534f12db0 | ||
|
|
8aed306751 | ||
|
|
7e95a1f57f | ||
|
|
e0e4c5e9db | ||
|
|
0c77cd5898 | ||
|
|
4848a9eff5 | ||
|
|
95b0cb05e7 | ||
|
|
723c4e7c2b | ||
|
|
c32f43b4b0 | ||
|
|
75071c8b9b | ||
|
|
641a5859a2 | ||
|
|
b26a050b2b | ||
|
|
bb19f898e4 | ||
|
|
bbc17333bd | ||
|
|
8630bbed74 | ||
|
|
7d17f516e5 | ||
|
|
0f0ca381d4 | ||
|
|
2c5baa6a60 | ||
|
|
e3d24ab1a5 | ||
|
|
ed35d477be | ||
|
|
7a236ba054 | ||
|
|
bf125a1a69 | ||
|
|
63a6a92bef | ||
|
|
8902d94a19 | ||
|
|
385dc78afc | ||
|
|
ca6140d05e | ||
|
|
cf93d0dd3a | ||
|
|
ac0f7d6030 | ||
|
|
2e9f807458 | ||
|
|
a7f07a08de | ||
|
|
42026ce09f | ||
|
|
0e528f7287 | ||
|
|
8d7ca30ea4 | ||
|
|
c8701964f4 | ||
|
|
6a8d226dde | ||
|
|
2c8ae8f2ef | ||
|
|
646a049d2d | ||
|
|
717fe15087 | ||
|
|
e39ec11e59 | ||
|
|
6bb4c0eda9 | ||
|
|
2f06f532e5 | ||
|
|
ac0a2f1275 | ||
|
|
092dee299b | ||
|
|
963782a17b | ||
|
|
7ca4fd68bd | ||
|
|
23ca4a855a | ||
|
|
16172da6b7 | ||
|
|
5720b15eff | ||
|
|
2e754b1297 | ||
|
|
70c8d7089d | ||
|
|
1ffa9ed25f | ||
|
|
4643ea9357 | ||
|
|
547b1d4959 | ||
|
|
e2918de5aa | ||
|
|
e6b0a03270 | ||
|
|
38865e907e | ||
|
|
b7615761ee | ||
|
|
8f198d9cbd | ||
|
|
93e0536aca | ||
|
|
396353c6b1 | ||
|
|
4c28a9b5b9 | ||
|
|
8ac6a79035 | ||
|
|
153e0d20a4 | ||
|
|
1e789800af | ||
|
|
74e0245c47 | ||
|
|
e5eacec45c | ||
|
|
cf23e5001a | ||
|
|
0e9233b230 | ||
|
|
01343707af | ||
|
|
466201a864 | ||
|
|
c7ba820eac | ||
|
|
2ebefd515c | ||
|
|
ddb4f9df58 | ||
|
|
c873af989a | ||
|
|
84baabff3c | ||
|
|
6622e459b2 | ||
|
|
600a863354 | ||
|
|
b77e4c4924 | ||
|
|
bd90908a72 | ||
|
|
1b4f056692 | ||
|
|
15d2dd04c4 | ||
|
|
3b03b98836 | ||
|
|
4e4767bbfe | ||
|
|
a2d34c7dbd | ||
|
|
8ca8cf121b | ||
|
|
9eff1ee00a | ||
|
|
26d9efa842 | ||
|
|
48aae09c66 | ||
|
|
e42acfc7ea | ||
|
|
f70fb7da5d | ||
|
|
99c9f7eaa5 | ||
|
|
6ddd66576d | ||
|
|
89564ffce8 | ||
|
|
fb9b6d4299 | ||
|
|
9fd16efb45 | ||
|
|
87d4324eb6 | ||
|
|
a8d445f56c | ||
|
|
16d2f19957 | ||
|
|
d82a6941d1 | ||
|
|
bb090e827e | ||
|
|
8426465800 | ||
|
|
427729f784 | ||
|
|
6a495df673 | ||
|
|
9eb3dc457b | ||
|
|
975b29a641 | ||
|
|
f7772b587f | ||
|
|
d1098c9a91 | ||
|
|
d50d56d837 | ||
|
|
8e2780a8f0 | ||
|
|
1feb0c7b0b | ||
|
|
8a5af429d7 | ||
|
|
23f1b63e9c | ||
|
|
8aec64cc36 | ||
|
|
2cd4ec9888 | ||
|
|
aa060c5fe1 | ||
|
|
07ff96c50c | ||
|
|
19c23a96c6 | ||
|
|
29d8c49bc2 | ||
|
|
f0c1ec9d84 | ||
|
|
755b7800c1 | ||
|
|
1d52dd41ba | ||
|
|
30149bfb41 | ||
|
|
0aa44ff207 | ||
|
|
8a00ab490a | ||
|
|
d486e88b5b | ||
|
|
93ecc8d0c1 | ||
|
|
0031c67d04 | ||
|
|
b6c527fb8c | ||
|
|
2726614191 | ||
|
|
88cbb484fd | ||
|
|
d3e7582474 | ||
|
|
08a3a5e8ef | ||
|
|
2e45a0750d | ||
|
|
8ad2c758ad | ||
|
|
5152376c4b | ||
|
|
8f331a10f5 | ||
|
|
32bd0123e2 | ||
|
|
cbba6ead3a | ||
|
|
95a3721df1 | ||
|
|
0f8225e608 | ||
|
|
b2ba0cb395 | ||
|
|
72f748a785 | ||
|
|
9ad798b74b | ||
|
|
4a296fffb2 | ||
|
|
41fc640974 | ||
|
|
300d65c317 | ||
|
|
18c8cc5eb9 | ||
|
|
f9baaaed27 | ||
|
|
72352e0006 | ||
|
|
d4da822ac2 | ||
|
|
26f8689238 |
No files matched your search
@@ -13,4 +13,3 @@
|
||||
87152661 rdbtCVS # added by Ludovic, 2026-10-27
|
||||
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
|
||||
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
|
||||
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
|
||||
@@ -75,6 +75,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
|
||||
| `horizon-pack-options` | A resource pack's own options as the game lists and keeps them (entries, controls, values, presets), whatever pack format declares them; engine, no dependencies |
|
||||
| `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 |
|
||||
|
||||
@@ -198,3 +198,5 @@ new pushes, we add it again.
|
||||
|
||||
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
|
||||
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
|
||||
|
||||
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
|
||||
Generated
+26
@@ -1590,6 +1590,7 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-pack-options",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
@@ -1650,6 +1651,15 @@ dependencies = [
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-discord"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-dualsense"
|
||||
version = "0.1.0"
|
||||
@@ -1793,14 +1803,25 @@ dependencies = [
|
||||
name = "horizon-official"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-discord",
|
||||
"horizon-friends",
|
||||
"horizon-game",
|
||||
"horizon-heartbeat",
|
||||
"horizon-official-servers",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-official-servers"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-addresses",
|
||||
"horizon-platform",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-optifine"
|
||||
version = "0.1.0"
|
||||
@@ -1811,6 +1832,10 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-pack-options"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
@@ -1828,6 +1853,7 @@ dependencies = [
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-pack-options",
|
||||
"log",
|
||||
"regex",
|
||||
"serde_json",
|
||||
|
||||
@@ -34,6 +34,8 @@ members = [
|
||||
"crates/horizon-game",
|
||||
"crates/horizon-official",
|
||||
"crates/horizon-heartbeat",
|
||||
"crates/horizon-discord",
|
||||
"crates/horizon-official-servers",
|
||||
"crates/horizon-bench",
|
||||
"crates/horizon-friends",
|
||||
"crates/horizon-relay",
|
||||
@@ -45,6 +47,7 @@ members = [
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-pack-options",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-deps",
|
||||
|
||||
+483
@@ -0,0 +1,483 @@
|
||||
# Horizon governance
|
||||
|
||||
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
|
||||
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
|
||||
|
||||
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
|
||||
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
|
||||
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
|
||||
stops.
|
||||
|
||||
## Contents
|
||||
|
||||
1. [Principles](#1-principles)
|
||||
2. [The levels at a glance](#2-the-levels-at-a-glance)
|
||||
3. [Each level in detail](#3-each-level-in-detail)
|
||||
4. [The council and its polls](#4-the-council-and-its-polls)
|
||||
5. [The selection filter](#5-the-selection-filter)
|
||||
6. [Theme groups](#6-theme-groups)
|
||||
7. [Moderation and appeals](#7-moderation-and-appeals)
|
||||
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
|
||||
9. [The age rule (18+)](#9-the-age-rule-18)
|
||||
10. [The agents](#10-the-agents)
|
||||
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
|
||||
12. [Money](#12-money)
|
||||
13. [Transparency](#13-transparency)
|
||||
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
|
||||
15. [Changing this document](#15-changing-this-document)
|
||||
|
||||
## 1. Principles
|
||||
|
||||
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
|
||||
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
|
||||
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
|
||||
he counts exactly like every other member.
|
||||
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
|
||||
requests, reviews, triage, support answers and days active. A vote then confirms it.
|
||||
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
|
||||
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
|
||||
not a sanction, and you can come back.
|
||||
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
|
||||
the result is published.
|
||||
- **18+ for roles of trust** (section 9).
|
||||
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
|
||||
|
||||
## 2. The levels at a glance
|
||||
|
||||
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
|
||||
the Horizon Team, for example.
|
||||
|
||||
| Level | What it can do | How you get in | How you leave | 18+ |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
|
||||
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
|
||||
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
|
||||
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
|
||||
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
|
||||
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
|
||||
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
|
||||
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
|
||||
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
|
||||
|
||||
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
|
||||
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
|
||||
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
|
||||
like everyone else.
|
||||
|
||||
## 3. Each level in detail
|
||||
|
||||
### Member
|
||||
|
||||
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
|
||||
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
|
||||
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
|
||||
|
||||
### Contributor
|
||||
|
||||
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
|
||||
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
|
||||
public horizon-rust repo) shows how much has been merged. Ranks only go up.
|
||||
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
|
||||
- **Alone:** their own work.
|
||||
|
||||
### Group member
|
||||
|
||||
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
|
||||
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
|
||||
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
|
||||
nominate a lead or vote in the group's advisory poll.
|
||||
|
||||
### 🤝 Support
|
||||
|
||||
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
|
||||
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
|
||||
|
||||
### 🛡️ Moderators and the Chief of Mods
|
||||
|
||||
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
|
||||
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
|
||||
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
|
||||
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
|
||||
same way (section 6, Server and SMP).
|
||||
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
|
||||
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
|
||||
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
|
||||
|
||||
### 🧭 Group leads
|
||||
|
||||
Section 6 says what a lead decides alone and what goes to the council.
|
||||
|
||||
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
|
||||
🧭 lead role carries no server-wide permission.
|
||||
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
|
||||
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
|
||||
the Horizon Team too.
|
||||
|
||||
### 🌌 Horizon Team
|
||||
|
||||
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
|
||||
diff, never merge your own pull request, and use `needs-proof` for anything risky.
|
||||
|
||||
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
|
||||
(`/nightly` and triage commands).
|
||||
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
|
||||
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
|
||||
one of those roles.
|
||||
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
|
||||
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
|
||||
|
||||
### 🏛️ Council
|
||||
|
||||
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
|
||||
starting polls in #polls-and-decisions.
|
||||
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
|
||||
release tools (section 11).
|
||||
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
|
||||
allow.
|
||||
- **Any two members together:** an emergency action (section 4.6).
|
||||
- **Everything else** is decided by poll.
|
||||
|
||||
## 4. The council and its polls
|
||||
|
||||
### 4.1 Founding council
|
||||
|
||||
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
|
||||
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
|
||||
**9** members. It adds a seat only when a 2/3 poll says it needs one.
|
||||
|
||||
### 4.2 How a poll works
|
||||
|
||||
| Rule | Value |
|
||||
| --- | --- |
|
||||
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
|
||||
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
|
||||
| How long | 24 hours. It closes early only if every council member has voted |
|
||||
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
|
||||
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
|
||||
| Simple majority | More Yes than No |
|
||||
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
|
||||
| Tie | Nothing changes |
|
||||
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
|
||||
| About yourself | You don't vote, and you don't count in the council's size for that poll |
|
||||
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
|
||||
|
||||
### 4.3 Which threshold applies
|
||||
|
||||
| Simple majority | Two-thirds |
|
||||
| --- | --- |
|
||||
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
|
||||
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
|
||||
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
|
||||
| Ending a probation early | Money (section 12) |
|
||||
| Confirming an emergency action | Account handover steps (section 11) |
|
||||
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
|
||||
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
|
||||
|
||||
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
|
||||
|
||||
### 4.4 Discussion
|
||||
|
||||
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
|
||||
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
|
||||
why. A failed poll can come back after 7 days.
|
||||
|
||||
### 4.5 Conflicts of interest
|
||||
|
||||
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
|
||||
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
|
||||
|
||||
### 4.6 Emergencies
|
||||
|
||||
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
|
||||
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
|
||||
members act at once**. They may:
|
||||
|
||||
- pause releases and the Nightly;
|
||||
- take a mod down from the registry;
|
||||
- remove someone's roles or access for now;
|
||||
- lock or hide channels and content;
|
||||
- ask the account holder to rotate a secret or a key.
|
||||
|
||||
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
|
||||
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
|
||||
adds members, changes rules or spends money.
|
||||
|
||||
### 4.7 Removing a council member
|
||||
|
||||
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
|
||||
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
|
||||
false age declaration (section 9).
|
||||
|
||||
### 4.8 When the council drops below a quorum
|
||||
|
||||
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
|
||||
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
|
||||
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
|
||||
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
|
||||
decisions wait. The Team keeps merging and the leads keep leading.
|
||||
- **Nobody left:** the Team and the group leads form the interim council in the same way.
|
||||
|
||||
## 5. The selection filter
|
||||
|
||||
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
|
||||
measures the work, and only people above a floor can be nominated. The council then decides among them.
|
||||
|
||||
### 5.1 What Kira measures
|
||||
|
||||
| Signal | What counts | Source |
|
||||
| --- | --- | --- |
|
||||
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
|
||||
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
|
||||
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
|
||||
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
|
||||
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
|
||||
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
|
||||
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
|
||||
| **Verified GitHub** | `/verify-github` done | Kira |
|
||||
| **18+ declaration** | Made (section 9) | Kira |
|
||||
|
||||
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
|
||||
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
|
||||
|
||||
### 5.2 The floors
|
||||
|
||||
A nomination is refused below these floors.
|
||||
|
||||
| Role | Floor |
|
||||
| --- | --- |
|
||||
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
|
||||
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
|
||||
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
|
||||
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
|
||||
|
||||
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
|
||||
Horizon and out of reach for people who don't.
|
||||
|
||||
### 5.3 The path to a role
|
||||
|
||||
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
|
||||
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
|
||||
needs a second from another active group member or a council member.
|
||||
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
|
||||
`/apply`, if a council or Team member sponsors them.
|
||||
- A **council member** can only be nominated by a council member.
|
||||
- **Moderators** apply with `/apply`.
|
||||
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
|
||||
is short.
|
||||
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
|
||||
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
|
||||
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
|
||||
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
|
||||
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
|
||||
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
|
||||
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
|
||||
majority) ended it earlier.
|
||||
|
||||
### 5.4 Exceptions
|
||||
|
||||
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
|
||||
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
|
||||
|
||||
### 5.5 Inactivity
|
||||
|
||||
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
|
||||
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
|
||||
holder or an org owner removes GitHub access when the Team role ends.
|
||||
|
||||
| Role | What counts as activity | Warning | Steps down |
|
||||
| --- | --- | --- | --- |
|
||||
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
|
||||
| Horizon Team | A merge or a review | Day 45 | Day 60 |
|
||||
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
|
||||
| Support | An answer in #horizon-support | Day 21 | Day 30 |
|
||||
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
|
||||
|
||||
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
|
||||
count. A lead names someone from the group to cover for them.
|
||||
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
|
||||
|
||||
### 5.6 Terms
|
||||
|
||||
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
|
||||
floor and is nominated, both go on the same poll.
|
||||
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
|
||||
is always possible (section 4.7).
|
||||
|
||||
## 6. Theme groups
|
||||
|
||||
### 6.1 The groups
|
||||
|
||||
| Group | Covers | GitHub | Discord |
|
||||
| --- | --- | --- | --- |
|
||||
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
|
||||
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
|
||||
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
|
||||
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
|
||||
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
|
||||
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
|
||||
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
|
||||
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
|
||||
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
|
||||
|
||||
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
|
||||
the council decides.
|
||||
|
||||
### 6.2 Leads per group
|
||||
|
||||
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
|
||||
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
|
||||
preferred: it spreads the work.
|
||||
|
||||
### 6.3 What a lead decides alone
|
||||
|
||||
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
|
||||
the design system:
|
||||
|
||||
- the group's priorities and the order of its roadmap items;
|
||||
- which issues are `good first issue`, and triage of the group's issues;
|
||||
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
|
||||
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
|
||||
take that decision to the council;
|
||||
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
|
||||
- asking the Team for reviews, adding `needs-proof`, and nominating people.
|
||||
|
||||
### 6.4 What goes to the council
|
||||
|
||||
- the rules, money, accounts and secrets;
|
||||
- who is on the council, the Team or the mods;
|
||||
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
|
||||
- breaking a public promise: the mod API, server compatibility, save and file formats;
|
||||
- disputes between groups;
|
||||
- anything involving Mojang, Microsoft or the law;
|
||||
- speaking for the whole project outside the group's topic.
|
||||
|
||||
### 6.5 A group without a lead
|
||||
|
||||
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
|
||||
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
|
||||
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
|
||||
nobody is appointed just to fill the seat.
|
||||
|
||||
## 7. Moderation and appeals
|
||||
|
||||
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
|
||||
change the rules (2/3).
|
||||
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
|
||||
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
|
||||
or the Chief of Mods, answers within 72 hours.
|
||||
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
|
||||
Members who were involved don't vote. The answer is final for 90 days.
|
||||
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
|
||||
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
|
||||
(simple majority). The council removes the Chief of Mods the same way.
|
||||
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
|
||||
|
||||
## 8. Conflicts and hard cases
|
||||
|
||||
| Case | What happens |
|
||||
| --- | --- |
|
||||
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
|
||||
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
|
||||
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
|
||||
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
|
||||
| Someone holds a role and is under 18 | Section 9 |
|
||||
| The council can't reach quorum | Section 4.8 |
|
||||
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
|
||||
|
||||
## 9. The age rule (18+)
|
||||
|
||||
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
|
||||
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
|
||||
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
|
||||
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
|
||||
|
||||
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
|
||||
18 are welcome and valued. They just can't hold the roles above.
|
||||
|
||||
**How it's checked, without collecting ID:**
|
||||
|
||||
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
|
||||
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
|
||||
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
|
||||
ID, a photo, or any outside age-checking service's result.
|
||||
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
|
||||
paused.
|
||||
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
|
||||
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
|
||||
within 72 hours in #council.
|
||||
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
|
||||
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
|
||||
any other step-down.
|
||||
|
||||
## 10. The agents
|
||||
|
||||
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
|
||||
|
||||
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
|
||||
members, and the standing rules in this document and the project's docs.
|
||||
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
|
||||
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
|
||||
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
|
||||
request, is that person using their role. The agents help, within the role's powers.
|
||||
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
|
||||
(section 13). A message saying "the council decided", or a screenshot, is not enough.
|
||||
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
|
||||
project's.
|
||||
|
||||
## 11. Accounts and the handover path
|
||||
|
||||
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
|
||||
unable to act, and never puts an account that is tied to a person into anyone else's hands.
|
||||
|
||||
| Account | Today | Can it be shared? | Plan |
|
||||
| --- | --- | --- | --- |
|
||||
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
|
||||
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
|
||||
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
|
||||
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
|
||||
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
|
||||
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
|
||||
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
|
||||
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
|
||||
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
|
||||
|
||||
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
|
||||
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
|
||||
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
|
||||
and the founder carries it out.
|
||||
|
||||
## 12. Money
|
||||
|
||||
*Placeholder: the options (Open Collective and others) are being researched.*
|
||||
|
||||
- The council approves every expense by a **2/3 poll**.
|
||||
- **Every income and every expense is public.**
|
||||
- Nobody votes on money paid to themselves.
|
||||
|
||||
## 13. Transparency
|
||||
|
||||
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
|
||||
For polls that aren't about a person, each member's vote is shown once the poll closes.
|
||||
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
|
||||
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
|
||||
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
|
||||
#polls-and-decisions.
|
||||
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
|
||||
the Chief of Mods, the moderators and Support.
|
||||
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
|
||||
|
||||
## 14. Rules that are never voted on
|
||||
|
||||
These are Horizon's conditions for existing, not choices. No poll can change them:
|
||||
|
||||
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
|
||||
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
|
||||
- **No gambling**, in any form, including with in-game currency.
|
||||
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
|
||||
- **The law**, and the terms of service of Discord and GitHub.
|
||||
|
||||
## 15. Changing this document
|
||||
|
||||
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
|
||||
(section 5.2) once a year, or sooner if they turn out wrong.
|
||||
@@ -135,6 +135,8 @@ python3 tools/pr_ready.py
|
||||
|
||||
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
|
||||
|
||||
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
|
||||
|
||||
## Project status
|
||||
|
||||
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
|
||||
|
||||
@@ -15,6 +15,10 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
|
||||
/// `official_server_previous` in `launcher/links.json`).
|
||||
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
|
||||
|
||||
/// Afri-Network, a partner server the game added once below the official server (#9299) until
|
||||
/// the partnership ended: kept so the game can take back out the entry it added.
|
||||
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
|
||||
|
||||
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
|
||||
/// (the telemetry server's status checks).
|
||||
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
|
||||
|
||||
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
|
||||
[dependencies]
|
||||
horizon-client-core = { path = "../horizon-client-core" }
|
||||
winit = "0.30.13"
|
||||
# The window icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
png = "0.18"
|
||||
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# The window icon test decodes Horizon's icon.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
|
||||
[features]
|
||||
controllers = []
|
||||
|
||||
|
||||
@@ -1441,6 +1441,16 @@ pub enum Rumble {
|
||||
},
|
||||
Totem,
|
||||
}
|
||||
impl From<crate::input_source::Feedback> for Rumble {
|
||||
fn from(event: crate::input_source::Feedback) -> Self {
|
||||
use crate::input_source::Feedback;
|
||||
match event {
|
||||
Feedback::BlockBroken => Rumble::BlockBroken,
|
||||
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
|
||||
Feedback::Totem => Rumble::Totem,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
|
||||
/// long it lasts.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
|
||||
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
|
||||
//! types, never a controller's; the controller code answers.
|
||||
|
||||
/// Something that happened to the player that a device may let them feel (a controller's
|
||||
/// vibration). The device decides how, or whether.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Feedback {
|
||||
/// The player broke a block.
|
||||
BlockBroken,
|
||||
/// An explosion this far away (blocks) with this power.
|
||||
Explosion { distance: f64, radius: f32 },
|
||||
/// A totem of undying saved the player.
|
||||
Totem,
|
||||
}
|
||||
@@ -9,6 +9,7 @@ pub mod gamepad;
|
||||
pub mod gameplay_guide;
|
||||
pub mod held;
|
||||
pub mod input;
|
||||
pub mod input_source;
|
||||
pub mod interaction_input;
|
||||
pub mod key_names;
|
||||
pub mod keybinds;
|
||||
|
||||
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
|
||||
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
|
||||
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
|
||||
/// game (the engine has none of its own).
|
||||
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
|
||||
|
||||
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
|
||||
/// keeps the first icon.
|
||||
pub fn set_icon_png(png: &'static [u8]) {
|
||||
let _ = ICON_PNG.set(png);
|
||||
}
|
||||
|
||||
/// The icon given to [`set_icon_png`], if any.
|
||||
pub fn icon_png() -> Option<&'static [u8]> {
|
||||
ICON_PNG.get().copied()
|
||||
}
|
||||
|
||||
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
|
||||
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
|
||||
/// can't be read.
|
||||
pub fn icon() -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
|
||||
decode_icon(icon_png()?)
|
||||
}
|
||||
|
||||
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
|
||||
decoder.ignore_checksums(true);
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
|
||||
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
|
||||
}
|
||||
|
||||
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
|
||||
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
|
||||
pub fn with_horizon_icon(
|
||||
attributes: winit::window::WindowAttributes,
|
||||
) -> winit::window::WindowAttributes {
|
||||
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
|
||||
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
|
||||
/// icon from appearing.
|
||||
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let icon = icon();
|
||||
#[cfg(target_os = "windows")]
|
||||
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
|
||||
let icon =
|
||||
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
|
||||
|
||||
let attributes = attributes.with_window_icon(icon.clone());
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -739,7 +760,8 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn the_window_icon_decodes() {
|
||||
assert!(icon().is_some());
|
||||
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
|
||||
assert!(decode_icon(b"not a png").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -70,7 +70,6 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
|
||||
mio = { version = "1.2.4", features = ["os-poll", "net"] }
|
||||
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
|
||||
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
|
||||
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
|
||||
horizon-bench = { path = "../horizon-bench" }
|
||||
@@ -109,17 +108,12 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
|
||||
[features]
|
||||
default = ["audio", "controllers", "horizon-extras"]
|
||||
default = ["audio", "controllers"]
|
||||
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
|
||||
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
|
||||
audio = ["horizon-client-render/audio"]
|
||||
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
|
||||
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
|
||||
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
|
||||
# server in the list (`official_servers`).
|
||||
horizon-extras = ["discord"]
|
||||
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
|
||||
discord = []
|
||||
# Records the call sites of mob random draws (`mob_ai_probe` traces).
|
||||
rng-trace = ["horizon-core/rng-trace"]
|
||||
# Compatibility with earlier experiment commands: main already uses mimalloc.
|
||||
@@ -131,6 +125,8 @@ windows-segment-heap = []
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# ui_theme's unit tests draw text in the design system's fonts.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-client-render = { path = "../horizon-client-render", features = ["test-support"] }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
|
||||
@@ -24,12 +24,12 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-pack-options = { path = "../horizon-pack-options" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
horizon-shaders = { path = "../horizon-shaders" }
|
||||
horizon-system = { path = "../horizon-system" }
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-world = { path = "../horizon-world" }
|
||||
ab_glyph = "0.2.32"
|
||||
@@ -76,10 +76,16 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
|
||||
audio = ["dep:cpal"]
|
||||
# Helpers horizon-client's tests build their fixtures with.
|
||||
test-support = []
|
||||
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
|
||||
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
|
||||
# the variable.
|
||||
horizon-extras = []
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# Tests: a TrueType font to load, and the design system the menus' motion curves match.
|
||||
horizon-theme = { path = "../horizon-theme" }
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "glsl-out", "msl-out", "spv-out"] }
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Audio occlusion, attenuation and mixing benchmark.
|
||||
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
|
||||
use horizon_client_render::{
|
||||
audio::Audio,
|
||||
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
|
||||
};
|
||||
use std::{path::Path, time::Instant};
|
||||
|
||||
fn main() {
|
||||
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
|
||||
|
||||
let mut audio = Audio::silent(Path::new("/nonexistent"));
|
||||
audio.engine.loaded = true;
|
||||
audio.backend_live_for_bench();
|
||||
audio.record_heard = false;
|
||||
|
||||
// Register a sound event with attenuation distance 16
|
||||
audio.engine.registry.add_file(
|
||||
"minecraft",
|
||||
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
// Insert 1 second of audio into cache (48000 mono samples)
|
||||
let samples: Vec<f32> = (0..48_000)
|
||||
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
|
||||
.collect();
|
||||
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
|
||||
|
||||
// 1. Benchmark set_listener (called every client render frame)
|
||||
let listener_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for i in 0..listener_iterations {
|
||||
let angle = (i as f32) * 0.01;
|
||||
audio.set_listener(
|
||||
[
|
||||
100.0 + (angle.cos() as f64) * 5.0,
|
||||
64.0,
|
||||
200.0 + (angle.sin() as f64) * 5.0,
|
||||
],
|
||||
angle,
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
let listener_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
|
||||
listener_elapsed,
|
||||
listener_iterations,
|
||||
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
|
||||
);
|
||||
|
||||
// 1b. Benchmark set_listener stationary (lock elimination early-out)
|
||||
let stationary_iterations = 200_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..stationary_iterations {
|
||||
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
|
||||
}
|
||||
let stationary_elapsed = start.elapsed();
|
||||
println!(
|
||||
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
|
||||
stationary_elapsed,
|
||||
stationary_iterations,
|
||||
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
|
||||
);
|
||||
|
||||
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
|
||||
// 64 sounds within attenuation distance (<16 blocks)
|
||||
// 64 sounds outside attenuation distance (>16 blocks)
|
||||
for i in 0..128 {
|
||||
let dist = if i < 64 {
|
||||
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
|
||||
} else {
|
||||
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
|
||||
};
|
||||
let pos = [100.0 + dist, 64.0, 200.0];
|
||||
let instance = SoundInstance::new(
|
||||
"minecraft:bench.loop",
|
||||
crate_sound_source(i),
|
||||
1.0,
|
||||
1.0,
|
||||
horizon_core::random::LegacyRandom::new(i as i64),
|
||||
true, // looping
|
||||
0,
|
||||
horizon_client_render::sound_engine::Attenuation::Linear,
|
||||
pos,
|
||||
false,
|
||||
);
|
||||
audio.play_instance(instance, false);
|
||||
}
|
||||
|
||||
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
|
||||
let mut buffer = vec![0.0f32; 1024 * 2];
|
||||
let mix_iterations = 5_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..mix_iterations {
|
||||
audio.mix_buffer(&mut buffer, 2, 48_000);
|
||||
}
|
||||
let mix_elapsed = start.elapsed();
|
||||
println!(
|
||||
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
|
||||
mix_elapsed,
|
||||
mix_iterations,
|
||||
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
|
||||
);
|
||||
|
||||
// 4. Benchmark SoundEngine::tick with ticking mob sounds
|
||||
let mut mobs = Vec::new();
|
||||
for i in 0..64 {
|
||||
mobs.push(MobSound {
|
||||
id: 2000 + i,
|
||||
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
|
||||
speed: 0.25,
|
||||
baby: false,
|
||||
angry: i % 2 == 0,
|
||||
silent: false,
|
||||
attack_scale: 0.5,
|
||||
sniffer_digging: false,
|
||||
});
|
||||
}
|
||||
|
||||
let tick_ctx = TickContext {
|
||||
player_underwater: false,
|
||||
player_removed: false,
|
||||
vehicle: None,
|
||||
carts: Vec::new(),
|
||||
mobs: mobs.clone(),
|
||||
frozen: false,
|
||||
player_position: [100.0, 64.0, 200.0],
|
||||
player_speed_sqr: 0.1,
|
||||
player_fall_flying: false,
|
||||
ridden: None,
|
||||
camera_position: [100.0, 64.0, 200.0],
|
||||
};
|
||||
|
||||
let tick_iterations = 10_000;
|
||||
let start = Instant::now();
|
||||
for _ in 0..tick_iterations {
|
||||
audio.tick(false, &tick_ctx);
|
||||
}
|
||||
let tick_elapsed = start.elapsed();
|
||||
println!(
|
||||
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
|
||||
tick_elapsed,
|
||||
tick_iterations,
|
||||
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
|
||||
);
|
||||
}
|
||||
|
||||
fn crate_sound_source(i: u32) -> usize {
|
||||
match i % 6 {
|
||||
0 => 0, // master
|
||||
1 => 1, // music
|
||||
2 => 2, // records
|
||||
3 => 3, // weather
|
||||
4 => 4, // blocks
|
||||
_ => 5, // hostile
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,8 @@ fn zip_pack(id: &str, path: PathBuf) -> Pack {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// The pack formats the game installs at startup, so their loading is measured too.
|
||||
horizon_client_render::pack_formats::install();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let scale = args
|
||||
.iter()
|
||||
|
||||
@@ -386,6 +386,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
for _ in 0..32 {
|
||||
draw(&mut renderer)?;
|
||||
|
||||
@@ -207,6 +207,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("cpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
let expected = read(&out.join("cpu.png"));
|
||||
@@ -251,6 +252,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: out.join("gpu.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame)?;
|
||||
assert_eq!(
|
||||
|
||||
@@ -167,6 +167,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
// twice: the first frame of a new warp builds it
|
||||
renderer.render(&frame).unwrap();
|
||||
@@ -336,6 +337,7 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
|
||||
path: out.join("bench.png"),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
renderer.render(&frame).unwrap();
|
||||
renderer.render(&frame).unwrap();
|
||||
|
||||
@@ -99,10 +99,8 @@ pub struct RenderAssets {
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
/// Fusion connected textures (empty without Fusion's connecting textures and models).
|
||||
pub fusion: crate::fusion::FusionTextures,
|
||||
/// The packs' connected textures (OptiFine's, Fusion's), when they have some.
|
||||
pub connected: Option<crate::connected::Connected>,
|
||||
/// Counts up each time resources load, from 1: a native mod asks again where blocks'
|
||||
/// sprites are when it changes (`native_block_sprites_version`).
|
||||
pub generation: u64,
|
||||
@@ -345,45 +343,32 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_oit_stage_{stage}"))?;
|
||||
}
|
||||
let fusion = if draws {
|
||||
let _step = crate::spans::startup::step("core.fusion");
|
||||
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut emissive = Vec::new();
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &models.catalog;
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
// Native mods' emissive textures go where the pack format puts them (after its
|
||||
// models' tiles, before its rules' tiles), else after both.
|
||||
let mut native_loaded = false;
|
||||
let mut native = |sprites: &mut crate::sprites::Sprites| {
|
||||
if !std::mem::replace(&mut native_loaded, true) {
|
||||
crate::native_emissive::load(root, sprites);
|
||||
}
|
||||
};
|
||||
let connected = crate::pack_hooks::formats().connected_textures(
|
||||
root,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
)
|
||||
&mut models.catalog,
|
||||
&mut native,
|
||||
);
|
||||
native(&mut sprites);
|
||||
connected
|
||||
} else {
|
||||
Default::default()
|
||||
None
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
if draws {
|
||||
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
|
||||
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
|
||||
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
|
||||
}
|
||||
sprites.save_disk_cache();
|
||||
if draws {
|
||||
@@ -411,7 +396,6 @@ impl RenderAssets {
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
fusion,
|
||||
generation: GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
|
||||
}
|
||||
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
|
||||
let rel = format!("assets/{namespace}/{directory}/{path}.png");
|
||||
root.join(if horizon_optifine::paths::valid(&rel) {
|
||||
&rel
|
||||
} else {
|
||||
NOWHERE
|
||||
})
|
||||
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
|
||||
let stays = rel
|
||||
.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
|
||||
root.join(if stays { &rel } else { NOWHERE })
|
||||
}
|
||||
|
||||
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
|
||||
|
||||
@@ -478,6 +478,7 @@ pub struct Audio {
|
||||
pub record_heard: bool,
|
||||
pub listener: [f64; 3],
|
||||
pub listener_yaw: f32,
|
||||
pub listener_pitch: f32,
|
||||
pub master: f32,
|
||||
/// Seeds of positional plays (the client level random's `nextLong`).
|
||||
random: LegacyRandom,
|
||||
@@ -722,6 +723,29 @@ impl Backend for MixerBackend {
|
||||
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
|
||||
self.with(channel, |c| c.unmuffled = unmuffled);
|
||||
}
|
||||
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
|
||||
if let Ok(mut m) = self.mixer.lock()
|
||||
&& let Some(c) = m.channels.get_mut(&channel)
|
||||
{
|
||||
c.gain = volume;
|
||||
c.pitch = pitch;
|
||||
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
|
||||
}
|
||||
}
|
||||
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
|
||||
if let Ok(m) = self.mixer.lock() {
|
||||
for &(h, c) in live {
|
||||
if m.channels
|
||||
.get(&c)
|
||||
.is_none_or(|ch| ch.state == State::Stopped)
|
||||
{
|
||||
stopped.push(h);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stopped.extend(live.iter().map(|&(h, _)| h));
|
||||
}
|
||||
}
|
||||
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
|
||||
/// `getStream` (decoded on a thread of its own), then attach and
|
||||
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
|
||||
@@ -822,18 +846,24 @@ impl Backend for MixerBackend {
|
||||
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
|
||||
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
|
||||
let length = (right * right + up * up + forward * forward).sqrt();
|
||||
stereo_gains_with_length(right, up, forward, length)
|
||||
}
|
||||
#[inline]
|
||||
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
|
||||
if length <= f32::EPSILON {
|
||||
return stereo_gains(0.0, 0.0, 1.0);
|
||||
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
|
||||
}
|
||||
let elevation = (up / length).clamp(-1.0, 1.0).asin();
|
||||
let u = (up / length).clamp(-1.0, 1.0);
|
||||
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
|
||||
let mut azimuth = right.atan2(forward);
|
||||
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
|
||||
azimuth = (azimuth.abs() * 1.5)
|
||||
.min(std::f32::consts::FRAC_PI_2)
|
||||
.copysign(azimuth);
|
||||
}
|
||||
let side = azimuth.sin() * elevation.cos();
|
||||
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
|
||||
let (sin_az, cos_az) = azimuth.sin_cos();
|
||||
let side = sin_az * cos_elev;
|
||||
let front = cos_az * cos_elev * 0.095_660_8;
|
||||
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
|
||||
}
|
||||
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
|
||||
@@ -853,16 +883,10 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// and OpenAL's right = forward x up.
|
||||
let basis = |yaw: f32, pitch: f32| {
|
||||
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
|
||||
let forward = [
|
||||
-yaw.sin() * pitch.cos(),
|
||||
-pitch.sin(),
|
||||
yaw.cos() * pitch.cos(),
|
||||
];
|
||||
let up = [
|
||||
-yaw.sin() * pitch.sin(),
|
||||
pitch.cos(),
|
||||
yaw.cos() * pitch.sin(),
|
||||
];
|
||||
let (yaw_sin, yaw_cos) = yaw.sin_cos();
|
||||
let (pitch_sin, pitch_cos) = pitch.sin_cos();
|
||||
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
|
||||
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
|
||||
let right = [
|
||||
forward[1] * up[2] - forward[2] * up[1],
|
||||
forward[2] * up[0] - forward[0] * up[2],
|
||||
@@ -896,7 +920,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
// Gains and pans per source for this buffer.
|
||||
let mut voices = std::mem::take(&mut mixer.voices);
|
||||
voices.clear();
|
||||
for (id, c) in &mixer.channels {
|
||||
for (id, c) in &mut mixer.channels {
|
||||
let Some((_, source_rate, source_channels)) = &c.samples else {
|
||||
continue;
|
||||
};
|
||||
@@ -907,6 +931,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if c.state != State::Playing {
|
||||
continue;
|
||||
}
|
||||
|
||||
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
|
||||
let away = |listener: [f64; 3]| {
|
||||
[
|
||||
@@ -915,45 +940,56 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
at[2] - listener[2],
|
||||
]
|
||||
};
|
||||
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
// The nearest camera hears it (one camera but in splitscreen).
|
||||
let camera = mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1));
|
||||
// Unheard sources still advance: returning to a world must not resume stale sounds.
|
||||
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
|
||||
let camera = if !shared {
|
||||
let cam = &mixer.cameras[0];
|
||||
if c.relative || cam.2 == c.world {
|
||||
Some((cam.0, cam.1))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
mixer
|
||||
.cameras
|
||||
.iter()
|
||||
.filter(|camera| c.relative || camera.2 == c.world)
|
||||
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
|
||||
.map(|camera| (camera.0, camera.1))
|
||||
};
|
||||
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
|
||||
let d = if c.relative { at } else { away(listener) };
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
|
||||
let gain = if camera.is_some() {
|
||||
c.gain.clamp(0.0, 1.0)
|
||||
* if direct {
|
||||
1.0
|
||||
} else {
|
||||
distance_gain(c.linear, distance)
|
||||
let d_sqr = length_sq(d);
|
||||
|
||||
let (gain, [left, right]) = if camera.is_none() {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else if direct {
|
||||
let gain = c.gain.clamp(0.0, 1.0);
|
||||
(gain, [gain, gain])
|
||||
} else {
|
||||
let mut zero = false;
|
||||
if let Some(max) = c.linear {
|
||||
let max_d = max as f64;
|
||||
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
|
||||
zero = true;
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let [left, right] = if direct {
|
||||
[gain, gain]
|
||||
} else {
|
||||
// Relative sources are already in OpenAL's listener space (-Z ahead).
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
|
||||
} else {
|
||||
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
|
||||
};
|
||||
if shared {
|
||||
// Several players on the speakers: half the stereo width, as left and right are
|
||||
// nobody's in particular.
|
||||
let middle = (left + right) / 2.0;
|
||||
left = middle + (left - middle) / 2.0;
|
||||
right = middle + (right - middle) / 2.0;
|
||||
}
|
||||
[left * gain, right * gain]
|
||||
if zero {
|
||||
(0.0, [0.0, 0.0])
|
||||
} else {
|
||||
let distance = d_sqr.sqrt() as f32;
|
||||
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
|
||||
let [mut left, mut right] = if c.relative {
|
||||
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
|
||||
} else {
|
||||
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
|
||||
};
|
||||
if shared {
|
||||
let middle = (left + right) * 0.5;
|
||||
left = middle + (left - middle) * 0.5;
|
||||
right = middle + (right - middle) * 0.5;
|
||||
}
|
||||
(gain, [left * gain, right * gain])
|
||||
}
|
||||
};
|
||||
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
|
||||
voices.push((*id, [left, right, gain], step));
|
||||
@@ -974,6 +1010,40 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
};
|
||||
let source_channels = source_channels as usize;
|
||||
let frames = samples.len() / source_channels;
|
||||
let inaudible = if channels >= 2 {
|
||||
left == 0.0 && right == 0.0
|
||||
} else {
|
||||
gain == 0.0
|
||||
};
|
||||
if inaudible {
|
||||
let buffer_frames = data.len() / channels;
|
||||
// As the loop below: a sound of one frame or none stops, looping or not.
|
||||
if !c.looping || frames <= 1 {
|
||||
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
|
||||
// reaches the end, as in the loop below.
|
||||
let needed = frames as f64 - 1.0 - c.position;
|
||||
if needed <= 0.0 {
|
||||
c.state = State::Stopped;
|
||||
} else if step > 0.0 {
|
||||
let steps_to_stop = needed / step;
|
||||
if (buffer_frames as f64) >= steps_to_stop {
|
||||
c.state = State::Stopped;
|
||||
} else {
|
||||
c.position += step * (buffer_frames as f64);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _ in 0..buffer_frames {
|
||||
let i = c.position as usize;
|
||||
if i + 1 >= frames {
|
||||
c.position = 0.0;
|
||||
}
|
||||
c.position += step;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let out: &mut [f32] = if muffling && !c.unmuffled {
|
||||
&mut bus
|
||||
} else {
|
||||
@@ -992,8 +1062,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
}
|
||||
let t = (c.position - i as f64) as f32;
|
||||
if source_channels > 1 {
|
||||
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
|
||||
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
|
||||
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
|
||||
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += l * left;
|
||||
frame[1] += r * right;
|
||||
@@ -1001,7 +1071,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
|
||||
frame[0] += (l + r) * 0.5 * gain;
|
||||
}
|
||||
} else {
|
||||
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
|
||||
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
|
||||
if channels >= 2 {
|
||||
frame[0] += s * left;
|
||||
frame[1] += s * right;
|
||||
@@ -1412,6 +1482,7 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: 1.0,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -1535,8 +1606,12 @@ impl Audio {
|
||||
/// `SoundManager.updateSource`: the camera position and rotation
|
||||
/// (`Listener.setTransform` takes its forward and up vectors).
|
||||
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
|
||||
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
|
||||
return;
|
||||
}
|
||||
self.listener = position;
|
||||
self.listener_yaw = yaw;
|
||||
self.listener_pitch = pitch;
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
if self.backend.owner == 0 {
|
||||
m.listener = position;
|
||||
@@ -1576,6 +1651,7 @@ impl Audio {
|
||||
record_heard: false,
|
||||
listener: [0.0; 3],
|
||||
listener_yaw: 0.0,
|
||||
listener_pitch: 0.0,
|
||||
master: self.master,
|
||||
random: crate::random_source::create(),
|
||||
source_override: None,
|
||||
@@ -2109,6 +2185,25 @@ impl Audio {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
|
||||
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
|
||||
if let Ok(mut m) = self.backend.mixer.lock() {
|
||||
mix(&mut m, data, channels, rate);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
|
||||
if let Ok(mut c) = self.backend.cache.lock() {
|
||||
c.insert(
|
||||
key.to_owned(),
|
||||
Some((std::sync::Arc::new(samples), rate, channels)),
|
||||
);
|
||||
}
|
||||
}
|
||||
#[doc(hidden)]
|
||||
pub fn backend_live_for_bench(&mut self) {
|
||||
self.backend.live = true;
|
||||
}
|
||||
}
|
||||
impl Sounds for Audio {
|
||||
fn engine(&mut self) -> &mut SoundEngine {
|
||||
@@ -2613,6 +2708,25 @@ mod tests {
|
||||
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
|
||||
}
|
||||
|
||||
/// A sound of one frame or none, heard or not, looping or not, stops at its first
|
||||
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
|
||||
#[test]
|
||||
fn sounds_too_short_to_mix_stop_even_unheard() {
|
||||
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
|
||||
let mut mixer = Mixer::default();
|
||||
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
|
||||
source.gain = 0.0;
|
||||
mixer.channels.insert(1, source);
|
||||
let mut data = [0.0; 4];
|
||||
mix(&mut mixer, &mut data, 2, 48_000);
|
||||
assert_eq!(
|
||||
mixer.channels[&1].state,
|
||||
State::Stopped,
|
||||
"{samples:?} {looping}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_of_one_sound_are_the_same_sound() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -960,7 +960,7 @@ pub struct Scene<'a> {
|
||||
pub world: &'a World,
|
||||
pub sprites: &'a HashMap<String, Rect>,
|
||||
/// The packs' OptiFine emissive overlays of the sprites.
|
||||
pub emissive: &'a horizon_optifine::emissive::Emissive,
|
||||
pub emissive: &'a crate::entity_textures::Emissive,
|
||||
/// Atlas sprites that contain a texel with fractional alpha. Static
|
||||
/// geometry stays on the cutout pipeline; these models keep the blended
|
||||
/// entity path so resource packs retain their alpha behavior.
|
||||
@@ -1039,7 +1039,7 @@ impl CacheOptions {
|
||||
}
|
||||
}
|
||||
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
|
||||
/// models over them ([`crate::block_entity_cem`]).
|
||||
/// models over them ([`crate::pack_hooks::PackFormats::block_entity_models`]).
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityShapes {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
@@ -1555,7 +1555,7 @@ impl BlockEntityRenderer {
|
||||
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
|
||||
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
|
||||
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
|
||||
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
|
||||
match crate::pack_hooks::formats().block_entity_models(root, &self.vanilla.models) {
|
||||
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
|
||||
None => self.vanilla.clone(),
|
||||
}
|
||||
@@ -4768,7 +4768,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
|
||||
use crate::block_entity_cem::tests::{BOX, chest, pack};
|
||||
use crate::pack_formats::block_entity_cem::tests::{BOX, chest, pack};
|
||||
crate::pack_formats::install();
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(
|
||||
HashMap::from([("chest", chest())]),
|
||||
HashMap::new(),
|
||||
@@ -4864,8 +4865,8 @@ mod tests {
|
||||
};
|
||||
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
|
||||
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
|
||||
// texture turns festive in late December.)
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests in
|
||||
// `pack_formats`; their texture turns festive in late December.)
|
||||
for (block, texture, u0, faces) in [
|
||||
("chest", "entity/chest/normal", 0.4, 8),
|
||||
("ender_chest", "entity/chest/ender", 0.2, 3),
|
||||
|
||||
@@ -1,116 +1,157 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
|
||||
//! `horizon_optifine::cit`'s.
|
||||
//! Custom item textures: rules a pack format vanilla doesn't read (OptiFine's and CIT Resewn's
|
||||
//! `cit/` folders) gives, which change a stack's texture, model, worn armour layers or
|
||||
//! enchantment glint. The platform's pack formats read and match them ([`crate::pack_hooks`]);
|
||||
//! the engine draws what the matching rule says ([`Rule`]).
|
||||
|
||||
pub use horizon_optifine::cit::{Hand, Kind, Rule};
|
||||
|
||||
use crate::cit_glint::{Global, StackGlint};
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
let props = read_properties(&root.join(&path))?;
|
||||
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
rules
|
||||
use horizon_items::items::ItemStack;
|
||||
|
||||
use crate::cit_glint::StackGlint;
|
||||
|
||||
/// What a rule changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Item,
|
||||
Armor,
|
||||
Elytra,
|
||||
Enchantment,
|
||||
}
|
||||
|
||||
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
|
||||
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
|
||||
/// be read.
|
||||
pub fn read_properties(path: &Path) -> Option<Properties> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
Some(Properties::parse_equals_first(&text))
|
||||
/// The hand a stack is drawn in; GUI items count as the main hand. `Any` is a rule's "either".
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hand {
|
||||
Any,
|
||||
Main,
|
||||
Off,
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
/// An OpenGL blend factor (`GL_ONE`, `GL_SRC_ALPHA`...), as `glBlendFuncSeparate` takes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Factor {
|
||||
Zero,
|
||||
One,
|
||||
SrcColor,
|
||||
OneMinusSrcColor,
|
||||
SrcAlpha,
|
||||
OneMinusSrcAlpha,
|
||||
DstAlpha,
|
||||
OneMinusDstAlpha,
|
||||
DstColor,
|
||||
OneMinusDstColor,
|
||||
SrcAlphaSaturate,
|
||||
}
|
||||
|
||||
/// A glint layer's blend factors for colour and alpha.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Blend {
|
||||
pub src: Factor,
|
||||
pub dst: Factor,
|
||||
pub src_alpha: Factor,
|
||||
pub dst_alpha: Factor,
|
||||
}
|
||||
|
||||
/// An enchantment rule's glint layer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Glint {
|
||||
pub layer: i32,
|
||||
/// `None` draws without blending (replace).
|
||||
pub blend: Option<Blend>,
|
||||
/// Scroll speed: the texture runs through vanilla's glint periods at `speed` times the
|
||||
/// clock in milliseconds.
|
||||
pub speed: f32,
|
||||
/// Degrees the texture turns by.
|
||||
pub rotation: f32,
|
||||
/// Seconds on this layer when the layers take turns.
|
||||
pub duration: f32,
|
||||
/// Multipliers of the texture's colour, never negative.
|
||||
pub rgba: [f32; 4],
|
||||
/// Whether the default glint still draws under this layer.
|
||||
pub use_glint: bool,
|
||||
/// Linear filtering, else nearest.
|
||||
pub blur: bool,
|
||||
}
|
||||
|
||||
/// A rule as the engine draws it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Rule {
|
||||
/// The rule's file, as a resource path.
|
||||
pub path: String,
|
||||
pub kind: Kind,
|
||||
/// Its texture, as a resource path to a `.png`.
|
||||
pub texture: Option<String>,
|
||||
/// Its named textures: (name, resource path).
|
||||
pub textures: Vec<(String, String)>,
|
||||
/// Its model, as a resource path to a `.json`.
|
||||
pub model: Option<String>,
|
||||
/// Its named models.
|
||||
pub models: Vec<(String, String)>,
|
||||
/// What an Enchantment rule draws.
|
||||
pub glint: Glint,
|
||||
}
|
||||
|
||||
/// Which rules a stack matches: the pack format's own conditions.
|
||||
pub trait Matcher: Send + Sync {
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into
|
||||
/// [`CitRules::rules`].
|
||||
fn select(&self, kind: Kind, stack: &ItemStack, hand: Hand, max_damage: u32) -> Option<usize>;
|
||||
/// Every matching Enchantment rule for the stack drawn in `hand`, in priority order.
|
||||
fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize>;
|
||||
/// The glint layers this stack draws in `hand`; `None` when the pack changes no glint.
|
||||
/// `foil` is whether the stack draws a glint at all.
|
||||
fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
foil: bool,
|
||||
) -> Option<Arc<StackGlint>>;
|
||||
/// Leave out the Enchantment rules `keep` refuses: they match no stack.
|
||||
fn keep_glints(&mut self, keep: &dyn Fn(usize) -> bool);
|
||||
}
|
||||
|
||||
/// The packs' custom item texture rules, the winner first, and their matcher.
|
||||
pub struct CitRules {
|
||||
pub rules: Vec<Rule>,
|
||||
/// The pack's `cit.properties`.
|
||||
pub global: Global,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
|
||||
/// Whether the default glint draws on items no enchantment rule matches.
|
||||
pub use_glint: bool,
|
||||
matcher: Option<Box<dyn Matcher>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
impl Default for CitRules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
global: crate::cit_glint::load_global(root),
|
||||
..Self::new(load_rules(root))
|
||||
rules: Vec::new(),
|
||||
use_glint: true,
|
||||
matcher: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
pub fn new(rules: Vec<Rule>) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
impl std::fmt::Debug for CitRules {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CitRules")
|
||||
.field("rules", &self.rules)
|
||||
.field("use_glint", &self.use_glint)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl CitRules {
|
||||
/// The rules of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
crate::pack_hooks::formats()
|
||||
.item_textures(root)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `rules`, in priority order, matched by `matcher`.
|
||||
pub fn new(rules: Vec<Rule>, use_glint: bool, matcher: Box<dyn Matcher>) -> Self {
|
||||
Self {
|
||||
rules,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
use_glint,
|
||||
matcher: Some(matcher),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,17 +162,12 @@ impl CitRules {
|
||||
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
|
||||
/// match no stack, so vanilla's glint and the other rules of their layer stay.
|
||||
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
|
||||
self.any_item.retain(|&i| keep(i as usize));
|
||||
let named = &mut self.by_item[Kind::Enchantment as usize];
|
||||
for list in named.values_mut() {
|
||||
list.retain(|&i| keep(i as usize));
|
||||
if let Some(matcher) = &mut self.matcher {
|
||||
matcher.keep_glints(&keep);
|
||||
}
|
||||
named.retain(|_, list| !list.is_empty());
|
||||
lock(&self.enchanted).clear();
|
||||
lock(&self.glints).clear();
|
||||
}
|
||||
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
|
||||
/// [`Matcher::select`].
|
||||
pub fn select(
|
||||
&self,
|
||||
kind: Kind,
|
||||
@@ -139,51 +175,17 @@ impl CitRules {
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
self.matcher.as_ref()?.select(kind, stack, hand, max_damage)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
|
||||
/// GUI), in priority order.
|
||||
/// [`Matcher::enchantments`].
|
||||
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
self.matcher
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |m| m.enchantments(stack, hand, max_damage))
|
||||
}
|
||||
|
||||
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
|
||||
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
|
||||
/// pack changes no glint. `foil` is whether the stack draws a glint at all
|
||||
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
|
||||
/// [`Matcher::glint`].
|
||||
pub fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
@@ -191,659 +193,6 @@ impl CitRules {
|
||||
max_damage: u32,
|
||||
foil: bool,
|
||||
) -> Option<Arc<StackGlint>> {
|
||||
let no_rules =
|
||||
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
|
||||
// Before the cache, which `foil` is not part of.
|
||||
if !foil || (no_rules && self.global.use_glint) {
|
||||
return None;
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(2, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
if let Some(hit) = lock(&self.glints).get(&key) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
let matched = self.enchantments(stack, hand, max_damage);
|
||||
let glint = Arc::new(StackGlint::new(
|
||||
&self.rules,
|
||||
&matched,
|
||||
stack.crafting_enchantments(),
|
||||
&self.global,
|
||||
));
|
||||
remember(&self.glints, key, glint.clone());
|
||||
Some(glint)
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches the stack drawn in `hand`.
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if rule.hand != Hand::Any && rule.hand != hand {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
let text = format!("texture=x\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
CitRules::new(rules)
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
|
||||
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
|
||||
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
|
||||
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
|
||||
}
|
||||
|
||||
/// `stack` with its `enchantment_glint_override` set to `on`.
|
||||
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
|
||||
let flag = (
|
||||
"minecraft:enchantment_glint_override".to_owned(),
|
||||
Tag::Byte(on.into()),
|
||||
);
|
||||
stack.data_mut().other.push(flag);
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_glint_reads_the_book_and_the_pack_file() {
|
||||
let mut cit = rules(&[
|
||||
("sharp", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"keep",
|
||||
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
|
||||
),
|
||||
]);
|
||||
// An enchanted book's glint follows its stored enchantments.
|
||||
let mut book = new("enchanted_book");
|
||||
book.data_mut().stored_enchantments = vec![
|
||||
(Arc::from("minecraft:sharpness"), 3),
|
||||
(Arc::from("minecraft:mending"), 1),
|
||||
];
|
||||
let book = glint(&cit, &book, true).unwrap();
|
||||
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(weights, [0.75, 0.25]);
|
||||
assert!(book.vanilla);
|
||||
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
|
||||
assert_eq!(plain.layers.len(), 1);
|
||||
assert!(!plain.vanilla);
|
||||
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
|
||||
let mut bare = rules(&[("stick", "items=stick")]);
|
||||
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
|
||||
bare.global.use_glint = false;
|
||||
assert!(
|
||||
!glint(&bare, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.vanilla
|
||||
);
|
||||
cit.global.use_glint = false;
|
||||
assert!(
|
||||
glint(&cit, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stack_with_a_glint_gets_layers() {
|
||||
let cit = rules(&[("any", "type=enchantment")]);
|
||||
assert!(glint(&cit, &new("stick"), false).is_none());
|
||||
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
|
||||
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
|
||||
assert!(glint(&cit, &off, false).is_none());
|
||||
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
|
||||
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(
|
||||
weights,
|
||||
[1.0],
|
||||
"nothing to share: the rule at full strength"
|
||||
);
|
||||
// The same stack, asked again without its glint, is not answered from the cache.
|
||||
assert!(
|
||||
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_left_out_of_the_glints_matches_nothing() {
|
||||
let mut cit = rules(&[
|
||||
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
|
||||
("shown", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |cit: &CitRules| -> Vec<String> {
|
||||
let glint = glint(cit, &sword, false).unwrap();
|
||||
let stems = glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(cit, Some(l.rule)).unwrap());
|
||||
stems.map(str::to_owned).collect()
|
||||
};
|
||||
// The heavier rule wins the layer, until its texture is missing.
|
||||
assert_eq!(layers(&cit), ["broken"]);
|
||||
let broken = (0..cit.rules.len())
|
||||
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
|
||||
.unwrap();
|
||||
cit.keep_glints(|i| i != broken);
|
||||
assert_eq!(layers(&cit), ["shown"]);
|
||||
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
|
||||
// None left: vanilla's glint stays.
|
||||
cit.keep_glints(|_| false);
|
||||
assert!(glint(&cit, &sword, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_hand_layer_draws_only_in_the_off_hand() {
|
||||
let cit = rules(&[
|
||||
("left", "type=enchantment\nlayer=1\nhand=off"),
|
||||
("both", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |hand| -> Vec<&str> {
|
||||
let glint = cit.glint(&sword, hand, 0, true).unwrap();
|
||||
glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(&cit, Some(l.rule)).unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(layers(Hand::Off), ["both", "left"]);
|
||||
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
|
||||
assert_eq!(layers(Hand::Main), ["both"]);
|
||||
self.matcher.as_ref()?.glint(stack, hand, max_damage, foil)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
|
||||
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
|
||||
|
||||
use crate::cit::{Kind, Rule};
|
||||
use crate::cit_assets::{rule_sprites, sprite_id};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
|
||||
@@ -114,10 +114,9 @@ pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
fn rule(path: &str, text: &str) -> Rule {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
|
||||
crate::pack_formats::cit::parse_rule(path, text).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
|
||||
//! `models/`) keeps its vanilla id, so it is not packed twice.
|
||||
|
||||
use crate::cit::{Kind, Rule};
|
||||
use horizon_assets::model::{CuboidModel, TextureSlot};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -55,7 +55,7 @@ pub fn model_id(resource: &str) -> Option<String> {
|
||||
/// and for one that would leave the resource root (a pack model may write any id).
|
||||
pub fn resource(id: &str, ext: &str) -> Option<String> {
|
||||
let rest = id.strip_prefix(NAMESPACE)?.strip_prefix(':')?;
|
||||
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
stays_in_root(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
}
|
||||
|
||||
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
|
||||
@@ -102,7 +102,14 @@ fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
|
||||
.and_then(|r| r.strip_suffix('.'))
|
||||
.unwrap_or(rest);
|
||||
let path = format!("{dir}/{rest}.{ext}");
|
||||
horizon_optifine::paths::valid(&path).then_some(path)
|
||||
stays_in_root(&path).then_some(path)
|
||||
}
|
||||
|
||||
/// Whether a resource path stays in the root: no `.`, `..` or empty segment, and no `\` (a
|
||||
/// separator on Windows, where `..\..` would climb).
|
||||
fn stays_in_root(path: &str) -> bool {
|
||||
path.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'))
|
||||
}
|
||||
|
||||
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
|
||||
@@ -294,11 +301,11 @@ mod tests {
|
||||
"base.json",
|
||||
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
|
||||
);
|
||||
let props =
|
||||
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
|
||||
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
|
||||
root.join(p).is_file()
|
||||
})
|
||||
let rule = crate::pack_formats::cit::parse_rule_in(
|
||||
&root,
|
||||
"assets/minecraft/optifine/cit/a.properties",
|
||||
"items=stick\nmodel=./swords/flame",
|
||||
)
|
||||
.unwrap();
|
||||
let models = load_models(&root, &[rule]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
|
||||
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
|
||||
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
|
||||
//!
|
||||
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
|
||||
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
|
||||
//! cycle's fading follow it, except where noted.
|
||||
|
||||
pub use horizon_optifine::cit::{Global, Method};
|
||||
|
||||
use crate::cit::read_properties;
|
||||
use horizon_optifine::cit::Rule;
|
||||
use std::path::Path;
|
||||
|
||||
/// The pack's global `cit.properties` under the overlay resource root: the first of
|
||||
/// `Global::PATHS` that exists, else the defaults.
|
||||
pub fn load_global(root: &Path) -> Global {
|
||||
Global::PATHS
|
||||
.iter()
|
||||
.find_map(|path| read_properties(&root.join(path)))
|
||||
.map(|props| Global::parse(&props))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
//! Custom item textures' enchantment glints: the layers a stack draws over (or instead of)
|
||||
//! vanilla's glint, and how strongly each draws over time. Which layers a stack gets is the pack
|
||||
//! format's ([`crate::cit::Matcher::glint`]); a layer's look is its rule's [`crate::cit::Glint`].
|
||||
|
||||
/// One glint layer a stack draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -33,6 +13,17 @@ pub struct GlintLayer {
|
||||
duration: f32,
|
||||
}
|
||||
|
||||
impl GlintLayer {
|
||||
/// Rule `rule`'s layer at `weight`, on for `duration` seconds when the layers take turns.
|
||||
pub fn new(rule: usize, weight: f32, duration: f32) -> Self {
|
||||
Self {
|
||||
rule,
|
||||
weight,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a stack's glint is made of, worked out when the stack changes.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackGlint {
|
||||
@@ -45,84 +36,14 @@ pub struct StackGlint {
|
||||
}
|
||||
|
||||
impl StackGlint {
|
||||
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
|
||||
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
|
||||
/// (`ItemStack::crafting_enchantments`).
|
||||
pub fn new(
|
||||
rules: &[Rule],
|
||||
matched: &[usize],
|
||||
enchantments: &[(impl AsRef<str>, u32)],
|
||||
global: &Global,
|
||||
) -> Self {
|
||||
// The highest priority rule of each layer.
|
||||
let mut layers: Vec<usize> = Vec::new();
|
||||
for &i in matched {
|
||||
let layer = rules[i].glint.layer;
|
||||
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
|
||||
layers.push(i);
|
||||
}
|
||||
}
|
||||
layers.sort_by_key(|&i| rules[i].glint.layer);
|
||||
let cycle = global.method == Method::Cycle;
|
||||
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
|
||||
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
|
||||
// the bottom layers, though, and caps cycles as well).
|
||||
if let Some(cap) = global.cap.filter(|_| !cycle) {
|
||||
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
|
||||
layers = layers.split_off(layers.len().saturating_sub(cap));
|
||||
}
|
||||
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
|
||||
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
|
||||
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
|
||||
let vanilla = global.use_glint
|
||||
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
|
||||
// CIT Resewn: shares are against every enchantment of the stack, not only those the
|
||||
// layers name.
|
||||
let levels = enchantments.iter().map(|(_, level)| *level as f32);
|
||||
let total = match global.method {
|
||||
Method::Average => levels.sum::<f32>(),
|
||||
Method::Layered => levels.fold(0.0, f32::max),
|
||||
Method::None | Method::Cycle => 0.0,
|
||||
};
|
||||
let shared = matches!(global.method, Method::Average | Method::Layered);
|
||||
let layers = layers
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let rule = &rules[i];
|
||||
let named = &rule.enchantments;
|
||||
let weight = if !shared || named.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
// The highest level among the enchantments the rule names whose level also
|
||||
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
|
||||
// stack has, whatever its level).
|
||||
let in_range = |level: u32| {
|
||||
let level = i32::try_from(level).unwrap_or(i32::MAX);
|
||||
rule.enchantment_levels
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.contains(level))
|
||||
};
|
||||
let level = enchantments
|
||||
.iter()
|
||||
.filter(|(id, level)| {
|
||||
let id: &str = id.as_ref();
|
||||
named.iter().any(|n| n == id) && in_range(*level)
|
||||
})
|
||||
.map(|(_, level)| *level as f32)
|
||||
.fold(0.0, f32::max);
|
||||
if total > 0.0 { level / total } else { 0.0 }
|
||||
};
|
||||
GlintLayer {
|
||||
rule: i,
|
||||
weight,
|
||||
duration: rule.glint.duration,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
/// `layers`, bottom first; `vanilla` whether vanilla's glint draws under them; `cycle_fade`
|
||||
/// the seconds each layer fades into the next when they take turns, else `None` (each
|
||||
/// draws at its weight).
|
||||
pub fn from_parts(layers: Vec<GlintLayer>, vanilla: bool, cycle_fade: Option<f32>) -> Self {
|
||||
Self {
|
||||
layers,
|
||||
vanilla,
|
||||
cycle_fade: cycle.then_some(global.fade),
|
||||
cycle_fade,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,208 +100,3 @@ fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64)
|
||||
// Not reached, as the last layer returns above; no layer rather than a panic.
|
||||
0.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
const DIR: &str = "assets/minecraft/optifine/cit";
|
||||
|
||||
/// An enchantment rule named `name` with `text` after `type` and `texture`.
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let text = format!("type=enchantment\ntexture=glint\n{text}");
|
||||
let path = format!("{DIR}/{name}.properties");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
fn global(text: &str) -> Global {
|
||||
Global::parse(&Properties::parse_equals_first(text))
|
||||
}
|
||||
|
||||
fn rules_of(layers: &StackGlint) -> Vec<usize> {
|
||||
layers.layers.iter().map(|l| l.rule).collect()
|
||||
}
|
||||
|
||||
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
|
||||
|
||||
#[test]
|
||||
fn one_rule_per_layer_bottom_first() {
|
||||
let rules = [
|
||||
rule("a", "layer=2"),
|
||||
rule("b", "layer=0"),
|
||||
rule("c", "layer=2"),
|
||||
rule("d", "layer=-1"),
|
||||
];
|
||||
// Priority order a, b, c, d: c loses layer 2 to a.
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [3, 1, 0]);
|
||||
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_and_layered_shares() {
|
||||
let rules = [
|
||||
rule("sharp", "enchantments=sharpness smite"),
|
||||
rule("unb", "layer=1\nenchantments=unbreaking"),
|
||||
rule("any", "layer=2"),
|
||||
rule("fire", "layer=3\nenchantments=fire_aspect"),
|
||||
];
|
||||
let weights = |method: &str| {
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
|
||||
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
|
||||
};
|
||||
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
|
||||
// A rule naming no enchantment is at full strength, one the stack lacks at none.
|
||||
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=cycle"), [1.0; 4]);
|
||||
assert_eq!(weights("method=none"), [1.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_level_is_one_the_rules_levels_allow() {
|
||||
let rules = [rule(
|
||||
"holy",
|
||||
"enchantments=sharpness smite\nenchantmentLevels=1-2",
|
||||
)];
|
||||
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
|
||||
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
|
||||
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
|
||||
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_keeps_the_topmost_layers() {
|
||||
let rules = [
|
||||
rule("a", "layer=0"),
|
||||
rule("b", "layer=1"),
|
||||
rule("c", "layer=2"),
|
||||
];
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
|
||||
assert_eq!(rules_of(&g), [1, 2]);
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty());
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
// Cycles show every layer in turn.
|
||||
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
|
||||
assert_eq!(rules_of(&g), [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_glint() {
|
||||
let plain = rule("plain", "");
|
||||
let keep = rule("keep", "layer=1\nuseGlint=true");
|
||||
let rules = [plain, keep];
|
||||
let vanilla = |matched: &[usize], text: &str| {
|
||||
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
|
||||
};
|
||||
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
|
||||
assert!(!vanilla(&[0], ""), "a rule replaces it");
|
||||
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
|
||||
assert!(!vanilla(&[], "useGlint=false"));
|
||||
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
|
||||
// Only the layers `cap` keeps count.
|
||||
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
|
||||
assert!(!g.vanilla);
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty() && g.vanilla);
|
||||
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
|
||||
assert!(!g.vanilla);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_take_turns_and_fade() {
|
||||
let rules = [
|
||||
rule("a", "duration=2"),
|
||||
rule("b", "layer=1\nduration=1"),
|
||||
rule("c", "layer=2\nduration=1"),
|
||||
];
|
||||
let g = StackGlint::new(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.5"),
|
||||
);
|
||||
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
|
||||
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
|
||||
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
|
||||
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
|
||||
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
|
||||
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
|
||||
// One layer always shows.
|
||||
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
|
||||
assert_eq!(one.intensity(0, 0.7), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_always_shows_a_whole_layer() {
|
||||
let rules = [
|
||||
rule("a", "duration=0.1"),
|
||||
rule("b", "layer=1\nduration=0.7"),
|
||||
rule("c", "layer=2\nduration=0.2"),
|
||||
];
|
||||
let g = StackGlint::new(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.3"),
|
||||
);
|
||||
// Every millisecond of the first two seconds, and just under a period, the last fade's
|
||||
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
|
||||
// defensive.)
|
||||
let period: f64 = [0.1f32, 0.7, 0.2]
|
||||
.map(|d| f64::from(d) + f64::from(0.3f32))
|
||||
.iter()
|
||||
.sum();
|
||||
let last = f64::from_bits(period.to_bits() - 1);
|
||||
for t in (0..2000_i32)
|
||||
.map(|i| f64::from(i) * 0.001)
|
||||
.chain([last, period + last])
|
||||
{
|
||||
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
|
||||
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
|
||||
}
|
||||
// No time at all: the bottom layer alone, never NaN.
|
||||
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
|
||||
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
|
||||
for t in [0.0, 0.5, 7.25] {
|
||||
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_global_file_wins() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(load_global(&root), Global::default());
|
||||
let write = |path: &str, text: &[u8]| {
|
||||
let file = root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, text).unwrap();
|
||||
};
|
||||
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
|
||||
write(
|
||||
"assets/minecraft/optifine/cit/cit.properties",
|
||||
b"# caf\xE9\nmethod=cycle",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write(
|
||||
"assets/minecraft/optifine/cit.properties",
|
||||
b"method=layered",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Layered);
|
||||
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
|
||||
let g = load_global(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,271 @@
|
||||
//! Entity textures a pack format vanilla doesn't read changes (OptiFine's random entities, ETF's
|
||||
//! blinking and emissive textures): the variants of each texture and how an entity picks one,
|
||||
//! the blink frames a texture shows now and then, and the overlays that glow over a texture.
|
||||
//! The platform's pack formats fill them once per pack load ([`crate::pack_hooks`]); drawing
|
||||
//! only looks them up in these tables.
|
||||
//!
|
||||
//! Textures have the names the entity atlas gives them (`entity/zombie/zombie`,
|
||||
//! `<ns>:<path>`); files outside `textures/` are listed with the name they take
|
||||
//! ([`RandomTextures::files`], [`Blinks::files`], [`Emissive::files`]) for the atlas to pack.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
|
||||
/// A pack's entity texture changes: variants, blinks and emissive overlays.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EntityTextures {
|
||||
pub random: RandomTextures,
|
||||
pub blinks: Blinks,
|
||||
pub emissive: Emissive,
|
||||
}
|
||||
|
||||
impl EntityTextures {
|
||||
/// The changes of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
crate::pack_hooks::formats()
|
||||
.entity_textures(root)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a variant rule can ask about an entity. `None` is a fact the entity doesn't have (a
|
||||
/// creeper's profession): a rule asking for it doesn't match.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Facts<'a> {
|
||||
/// Where the entity was first seen: its biome (`minecraft:plains`) and block height.
|
||||
pub biome: Option<&'a str>,
|
||||
pub y: Option<i32>,
|
||||
/// The block it stood on and the block it stood in when first seen; empty when unknown.
|
||||
pub blocks: &'a [Block<'a>],
|
||||
/// Its custom name, with `§` formatting codes where the name has them.
|
||||
pub name: Option<&'a str>,
|
||||
/// Villager profession (`minecraft:farmer`) and level.
|
||||
pub profession: Option<(&'a str, i32)>,
|
||||
/// Collar, wool, carpet or shell colour, as a dye name (`light_blue`).
|
||||
pub color: Option<&'a str>,
|
||||
pub baby: Option<bool>,
|
||||
/// Health and max health.
|
||||
pub health: Option<(f32, f32)>,
|
||||
/// When first seen: moon phase (0-7), time of day (0-23999) and weather.
|
||||
pub moon_phase: Option<i32>,
|
||||
pub day_time: Option<i32>,
|
||||
pub weather: Option<Weather>,
|
||||
/// Slime, magma cube or phantom size: 0 small, 1 medium, 3 big.
|
||||
pub size: Option<i32>,
|
||||
}
|
||||
|
||||
/// A block and the values of its state properties.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Block<'a> {
|
||||
pub id: &'a str,
|
||||
pub states: &'a [(&'a str, &'a str)],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
|
||||
/// How a pack format's rule files choose a variant.
|
||||
pub trait VariantRules: Send + Sync {
|
||||
/// The variant rule file `rules` ([`Entry::rules`]) gives an entity with these facts and
|
||||
/// seed, `None` when no rule matches.
|
||||
fn matching(&self, rules: usize, facts: &Facts, seed: i32) -> Option<u32>;
|
||||
}
|
||||
|
||||
/// Every texture a pack varies, by its atlas name.
|
||||
#[derive(Default)]
|
||||
pub struct RandomTextures {
|
||||
entries: HashMap<String, Entry>,
|
||||
/// Every variant's file (from the resource root) and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
rules: Option<Box<dyn VariantRules>>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RandomTextures {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RandomTextures")
|
||||
.field("entries", &self.entries)
|
||||
.field("generation", &self.generation)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// One texture's variants.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// The rule file that chooses this texture's variant ([`RandomTextures::matching`]).
|
||||
pub rules: Option<usize>,
|
||||
/// The atlas name of each variant, by its number.
|
||||
pub variants: BTreeMap<u32, String>,
|
||||
}
|
||||
|
||||
impl RandomTextures {
|
||||
/// `entries` by atlas name, their variants' `files` (file, atlas name), the rule files
|
||||
/// `rules` that choose, and `generation`: different for every load (never 0), so choices
|
||||
/// made from another load's rules are known to be stale.
|
||||
pub fn new(
|
||||
entries: HashMap<String, Entry>,
|
||||
files: Vec<(String, String)>,
|
||||
rules: Box<dyn VariantRules>,
|
||||
generation: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
entries,
|
||||
files,
|
||||
rules: Some(rules),
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// This load's number; 0 for the empty set.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// The variants of the texture with this atlas name.
|
||||
pub fn get(&self, texture: &str) -> Option<&Entry> {
|
||||
self.entries.get(texture)
|
||||
}
|
||||
|
||||
/// [`VariantRules::matching`].
|
||||
pub fn matching(&self, rules: usize, facts: &Facts, seed: i32) -> Option<u32> {
|
||||
self.rules.as_ref()?.matching(rules, facts, seed)
|
||||
}
|
||||
|
||||
/// Every variant's file (its path from the resource root) and atlas name, for the entity
|
||||
/// atlas to pack.
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The atlas name of variant `index`, or `None` for the texture itself.
|
||||
pub fn texture(&self, index: u32) -> Option<&str> {
|
||||
self.variants.get(&index).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The even choice of an entity with this seed when no rules apply: the texture and its
|
||||
/// variants 2, 3... up to the first missing one.
|
||||
pub fn even(&self, seed: i32) -> u32 {
|
||||
let mut count = 1;
|
||||
while self.variants.contains_key(&(count + 1)) {
|
||||
count += 1;
|
||||
}
|
||||
seed.unsigned_abs() % count + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity's seed from its UUID (most then least significant 64 bits): Java's
|
||||
/// `UUID.hashCode`, so it is fixed for the entity's whole life, on every client.
|
||||
pub fn seed(most: i64, least: i64) -> i32 {
|
||||
let x = most ^ least;
|
||||
((x >> 32) ^ x) as i32
|
||||
}
|
||||
|
||||
/// The pack's blinking textures, by the atlas name of the texture they replace.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Blinks {
|
||||
blinks: HashMap<String, Blink>,
|
||||
/// The blink frames outside `textures/`: file and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A texture's blink.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Blink {
|
||||
/// The atlas names of the shut and half-shut frames.
|
||||
pub shut: String,
|
||||
pub half: Option<String>,
|
||||
/// Ticks a blink lasts (at least 1) and ticks from one blink to the next (more).
|
||||
pub length: u32,
|
||||
pub frequency: u32,
|
||||
}
|
||||
|
||||
impl Blinks {
|
||||
/// `blinks` by the atlas name of the texture they replace, and the frames outside
|
||||
/// `textures/` (file, atlas name).
|
||||
pub fn new(blinks: HashMap<String, Blink>, files: Vec<(String, String)>) -> Self {
|
||||
Self { blinks, files }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.blinks.is_empty()
|
||||
}
|
||||
|
||||
pub fn get(&self, texture: &str) -> Option<&Blink> {
|
||||
self.blinks.get(texture)
|
||||
}
|
||||
|
||||
/// The frames outside `textures/`: file and atlas name, for the entity atlas to pack (it
|
||||
/// walks `textures/` itself).
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Blink {
|
||||
/// The frame an entity shows `tick` ticks into its life: `None` with its eyes open. `phase`
|
||||
/// spreads entities' blinks apart. With a half-shut frame, the first and last ticks of a
|
||||
/// blink of 3 or more show it.
|
||||
pub fn frame(&self, tick: u32, phase: u32) -> Option<&str> {
|
||||
let t = (u64::from(tick) + u64::from(phase)) % u64::from(self.frequency);
|
||||
let t = t as u32;
|
||||
if t >= self.length {
|
||||
return None;
|
||||
}
|
||||
match &self.half {
|
||||
Some(half) if self.length >= 3 && (t == 0 || t == self.length - 1) => Some(half),
|
||||
_ => Some(&self.shut),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's emissive overlays, by the atlas name of the texture they glow over.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Emissive {
|
||||
/// The name ending of the pack's emissive textures (`_e`).
|
||||
pub suffix: String,
|
||||
overlays: HashMap<String, String>,
|
||||
/// Overlays outside `textures/`: file and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Emissive {
|
||||
/// `overlays` (texture → overlay, atlas names) with name ending `suffix`, and those outside
|
||||
/// `textures/` (file, atlas name).
|
||||
pub fn new(
|
||||
suffix: String,
|
||||
overlays: HashMap<String, String>,
|
||||
files: Vec<(String, String)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
suffix,
|
||||
overlays,
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.overlays.is_empty()
|
||||
}
|
||||
|
||||
/// The atlas name of the overlay that glows over `texture`.
|
||||
pub fn overlay(&self, texture: &str) -> Option<&str> {
|
||||
self.overlays.get(texture).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The overlays outside `textures/`: file (from the resource root) and atlas name, for the
|
||||
/// entity atlas to pack (it walks `textures/` itself).
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,7 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
("config", [horizon_polytone::expr::Value::Str(id)]) => self
|
||||
.configs
|
||||
.get(id)
|
||||
.map(horizon_polytone::pack::ConfigValue::to_value)
|
||||
.map(horizon_polytone::pack::config_value)
|
||||
.ok_or_else(|| format!("no config {id}")),
|
||||
_ => Err(format!(
|
||||
"{name} is not available in an attribute expression"
|
||||
@@ -569,6 +569,25 @@ pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
|
||||
}
|
||||
|
||||
impl horizon_polytone::expr::Host for BlockHost<'_> {
|
||||
fn field(
|
||||
&mut self,
|
||||
root: horizon_polytone::expr::Root,
|
||||
field: horizon_polytone::expr::Field,
|
||||
) -> Option<horizon_polytone::expr::Value> {
|
||||
use horizon_polytone::expr::Field as F;
|
||||
if root != horizon_polytone::expr::Root::Object {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_polytone::expr::Value::Num(match field {
|
||||
F::X => f64::from(self.at[0]),
|
||||
F::Y => f64::from(self.at[1]),
|
||||
F::Z => f64::from(self.at[2]),
|
||||
F::Temperature => f64::from(self.biome.temperature),
|
||||
F::Downfall => f64::from(self.biome.downfall),
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
|
||||
use horizon_polytone::expr::Root;
|
||||
horizon_polytone::expr::Value::Object(match root {
|
||||
@@ -1084,6 +1103,7 @@ mod tests {
|
||||
for (path, text) in pack {
|
||||
write(&format!("pack/{path}"), text);
|
||||
}
|
||||
crate::pack_formats::install();
|
||||
let modifiers = PackModifiers::read(&dir.join("pack")).with_configs(
|
||||
crate::packs::polytone_configs(&crate::packs::Source::Dir(dir.join("pack"))),
|
||||
);
|
||||
|
||||
@@ -112,7 +112,11 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
|
||||
};
|
||||
Row::new(id, lang::get(label), value)
|
||||
}
|
||||
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Button {
|
||||
label,
|
||||
active: true,
|
||||
..
|
||||
} => Row::new(id, lang::get(label), Value::Press),
|
||||
added::Row::Key { label, key } => {
|
||||
let key = crate::keybinds::display_name(&key).plain();
|
||||
Row::new(id, lang::get(label), Value::Read(key))
|
||||
@@ -490,9 +494,7 @@ mod tests {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::Button {
|
||||
label: "test.elsewhere.button",
|
||||
});
|
||||
rows.push(added::Row::button("test.elsewhere.button"));
|
||||
}
|
||||
fn act(&self, _: usize, _: Act) {}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,8 @@ pub struct FogInputs<'a> {
|
||||
pub night_vision: f32,
|
||||
/// The packs' `environment` fog settings (Polytone's).
|
||||
pub environment: crate::pack_palette::Environment,
|
||||
/// The packs' fluid modifiers' fog.
|
||||
pub fluid_fog: crate::pack_palette::FluidFogs,
|
||||
}
|
||||
|
||||
/// `FogRenderer.setupFog` + `computeFogColor`.
|
||||
@@ -397,6 +399,24 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
|
||||
}
|
||||
}
|
||||
}
|
||||
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
|
||||
let fluid = match input.fog_type {
|
||||
FogType::Lava => input.fluid_fog.lava,
|
||||
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
|
||||
input.fluid_fog.water
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(f) = fluid {
|
||||
if let Some(rgb) = f.color {
|
||||
let [r, g, b] = self::rgb(rgb);
|
||||
fog.color = [r, g, b, 1.0];
|
||||
}
|
||||
let end = fog.environmental_end * f.radius;
|
||||
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
|
||||
(fog.environmental_start, fog.environmental_end) = (start, end);
|
||||
(fog.sky_end, fog.cloud_end) = (end, end);
|
||||
}
|
||||
let span = (rd / 10.0).clamp(4.0, 64.0);
|
||||
fog.render_distance_start = rd - span;
|
||||
fog.render_distance_end = rd;
|
||||
@@ -495,6 +515,7 @@ mod tests {
|
||||
full_water_vision: false,
|
||||
night_vision: 0.0,
|
||||
environment: Default::default(),
|
||||
fluid_fog: Default::default(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
@@ -611,6 +632,38 @@ mod tests {
|
||||
let kept = setup_fog(&tables, &state, &input).color;
|
||||
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
|
||||
}
|
||||
#[test]
|
||||
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
|
||||
let sky = Sky {
|
||||
fog_start: 10.0,
|
||||
fog_end: 96.0,
|
||||
..Sky::default()
|
||||
};
|
||||
let tables = FogTables {
|
||||
water_fog_end: vec![1.0],
|
||||
};
|
||||
let mut input = inputs(&sky);
|
||||
input.fog_type = FogType::Lava;
|
||||
input.fire_resistance = true;
|
||||
let vanilla = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(
|
||||
(vanilla.environmental_start, vanilla.environmental_end),
|
||||
(0.0, 5.0)
|
||||
);
|
||||
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
|
||||
color: Some(0x00FF00),
|
||||
radius: 2.0,
|
||||
fade: 0.5,
|
||||
});
|
||||
let fog = setup_fog(&tables, &FogState::default(), &input);
|
||||
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
|
||||
// The end doubles; half the fade from 0 to 10 starts it at 5.
|
||||
assert_eq!(
|
||||
(fog.environmental_start, fog.environmental_end),
|
||||
(5.0, 10.0)
|
||||
);
|
||||
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
|
||||
}
|
||||
fn inputs_with<'a>(
|
||||
input: &FogInputs<'a>,
|
||||
environment: crate::pack_palette::Environment,
|
||||
|
||||
@@ -201,6 +201,7 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
|
||||
mod hold;
|
||||
pub use hold::WorldHold;
|
||||
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
|
||||
pub use screenshot_save::Saved as SavedScreenshot;
|
||||
pub mod block_light;
|
||||
mod custom_sky;
|
||||
mod displace;
|
||||
@@ -236,6 +237,7 @@ pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod reuse;
|
||||
mod screenshot_save;
|
||||
mod stars;
|
||||
mod surface_setup;
|
||||
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
|
||||
@@ -1084,6 +1086,9 @@ pub struct ScreenshotRequest {
|
||||
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
|
||||
/// `Renderer::screenshot_quality`, not the window's.
|
||||
pub scaled: bool,
|
||||
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
|
||||
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
|
||||
pub background: bool,
|
||||
}
|
||||
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
|
||||
/// for the frames HDR is off, and with it where the display offers HDR output: the
|
||||
@@ -1578,6 +1583,9 @@ pub struct Renderer {
|
||||
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
|
||||
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
|
||||
pub screenshot_hdr: Option<std::path::PathBuf>,
|
||||
/// `background` screenshots written since the caller last took them.
|
||||
pub saved_screenshots: Vec<SavedScreenshot>,
|
||||
screenshot_saves: screenshot_save::Saves,
|
||||
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
|
||||
/// times the game's resolution (1, 2, 4 or 8).
|
||||
pub screenshot_quality: u32,
|
||||
@@ -5077,6 +5085,7 @@ impl Renderer {
|
||||
));
|
||||
}
|
||||
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
|
||||
let screenshot_saves = screenshot_save::Saves::new(&device);
|
||||
Ok(Self {
|
||||
display: None,
|
||||
reading: display,
|
||||
@@ -5348,6 +5357,8 @@ impl Renderer {
|
||||
screenshot: None,
|
||||
screenshot_result: None,
|
||||
screenshot_hdr: None,
|
||||
saved_screenshots: Vec::new(),
|
||||
screenshot_saves,
|
||||
screenshot_quality: 1,
|
||||
screenshot_halvings: 0,
|
||||
gamma: 0.5,
|
||||
@@ -7041,8 +7052,10 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
/// This renderer's output as applied, with what its destination offers and reports.
|
||||
pub fn output(&self) -> &output::EffectiveOutput {
|
||||
&self.output
|
||||
pub fn output(&self) -> output::EffectiveOutput {
|
||||
let native = self.native.as_ref().map(|n| n.frame.api);
|
||||
self.output
|
||||
.with_pack(self.shader_pack_kind() == Some("iris"), native)
|
||||
}
|
||||
/// What this renderer's display last reported: a renderer made again for the window
|
||||
/// starts from it (`Renderer::new`'s `display`).
|
||||
@@ -7975,6 +7988,8 @@ impl Renderer {
|
||||
if self.device_lost() {
|
||||
return Err("the graphics device was lost".into());
|
||||
}
|
||||
let saved = self.screenshot_saves.finished();
|
||||
self.saved_screenshots.extend(saved);
|
||||
if let Some(request) = self.capture.take() {
|
||||
let result = self.write_capture(&request, frame);
|
||||
self.capture_result = Some(result.map_err(|e| e.to_string()));
|
||||
@@ -8319,9 +8334,7 @@ impl Renderer {
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
|
||||
let hdr_lighting = self.native.is_none()
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
|
||||
let hdr_lighting = self.shader_pack_kind().is_none();
|
||||
if let Some(hdr) = &mut self.hdr {
|
||||
hdr.lighting = hdr_lighting && hdr.look;
|
||||
}
|
||||
@@ -12044,24 +12057,18 @@ impl Renderer {
|
||||
self.screenshot = Some(request);
|
||||
} else {
|
||||
self.screenshot_halvings = 0;
|
||||
self.screenshot_hdr = None;
|
||||
if let Some(hdr_buffer) = hdr_readback {
|
||||
let hdr_path = hdr_screenshot_path(&request.path);
|
||||
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
self.screenshot_hdr = Some(hdr_path);
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
let size = [self.config.width, self.config.height];
|
||||
let hdr = hdr_readback
|
||||
.filter(|_| self.hdr.is_some())
|
||||
.map(|b| (hdr_screenshot_path(&request.path), b));
|
||||
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
|
||||
if request.background {
|
||||
self.screenshot_saves.start(shot, buffer);
|
||||
} else {
|
||||
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
|
||||
self.screenshot_hdr = saved.hdr;
|
||||
self.screenshot_result = Some(saved.result);
|
||||
}
|
||||
self.screenshot_result = Some(
|
||||
self.write_screenshot(&request, &buffer)
|
||||
.map_err(|e| e.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
// The frame's own waits for the submit thread (its queue writes and passes).
|
||||
@@ -12664,96 +12671,22 @@ impl Renderer {
|
||||
let texture = copy.as_ref().unwrap_or(texture);
|
||||
Some((request, copy_frame(&self.device, encoder, texture, size)))
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(format!(
|
||||
let error = format!(
|
||||
"screenshots need a 32-bit surface format, not {:?}",
|
||||
self.config.format
|
||||
)));
|
||||
);
|
||||
if request.background {
|
||||
self.saved_screenshots.push(screenshot_save::Saved {
|
||||
path: request.path,
|
||||
result: Err(error),
|
||||
hdr: None,
|
||||
});
|
||||
} else {
|
||||
self.screenshot_result = Some(Err(error));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
|
||||
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = sender.send(r);
|
||||
});
|
||||
self.device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string())?;
|
||||
receiver
|
||||
.recv()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (width, height, row) = (
|
||||
self.config.width as usize,
|
||||
self.config.height as usize,
|
||||
padded_row(self.config.width) as usize,
|
||||
);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_screenshot(
|
||||
&self,
|
||||
request: &ScreenshotRequest,
|
||||
buffer: &wgpu::Buffer,
|
||||
) -> Result<[u32; 2]> {
|
||||
let bgra = matches!(
|
||||
self.config.format.remove_srgb_suffix(),
|
||||
wgpu::TextureFormat::Bgra8Unorm
|
||||
);
|
||||
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
|
||||
for px in self.read_frame(buffer)? {
|
||||
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match request.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, self.config.width, self.config.height, size),
|
||||
),
|
||||
None => (self.config.width, self.config.height, pixels),
|
||||
};
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
|
||||
width,
|
||||
height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.write_header()?.write_image_data(&pixels)?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
if self.hdr.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut info = png::Info::with_size(self.config.width, self.config.height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let encoder =
|
||||
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
|
||||
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
|
||||
|
||||
@@ -191,7 +191,7 @@ pub struct Hdr {
|
||||
pub masks: Masks,
|
||||
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
|
||||
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
|
||||
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
|
||||
/// and with HDR Lighting (`look`) off.
|
||||
pub lighting: bool,
|
||||
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
|
||||
/// sun included, so the picture is SDR's on the HDR canvas.
|
||||
@@ -1001,12 +1001,14 @@ impl Hdr {
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
|
||||
/// edges, are the logo; everything else is not.
|
||||
/// The windows' icon (`window::set_icon_png`: Horizon's in the official game) as a 1-bit mask:
|
||||
/// its white strokes, one colour with hard edges, are the logo; everything else is not. No icon
|
||||
/// given: an empty mask.
|
||||
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
|
||||
use wgpu::util::DeviceExt;
|
||||
let (w, h, rgba) =
|
||||
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
|
||||
let (w, h, rgba) = horizon_client_input::window::icon_png()
|
||||
.and_then(|png| crate::hud::decode_bytes(png).ok())
|
||||
.unwrap_or((1, 1, vec![0; 4]));
|
||||
let mask: Vec<u8> = rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
|
||||
@@ -144,6 +144,9 @@ pub struct EffectiveOutput {
|
||||
pub(crate) nits: [f32; 3],
|
||||
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
|
||||
pub(crate) ui_brightness: f32,
|
||||
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
|
||||
/// Lighting has nothing to switch (`with_pack`).
|
||||
pub(crate) pack_lights: bool,
|
||||
}
|
||||
impl Default for EffectiveOutput {
|
||||
fn default() -> Self {
|
||||
@@ -166,7 +169,22 @@ impl EffectiveOutput {
|
||||
},
|
||||
nits: [203.0, 1000.0, 0.0],
|
||||
ui_brightness: 1.0,
|
||||
pack_lights: false,
|
||||
};
|
||||
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
|
||||
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
|
||||
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
|
||||
/// `Renderer::render`); under the rest Horizon lifts nothing.
|
||||
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
|
||||
let pack_lights = shaders
|
||||
|| native.is_some_and(|api| {
|
||||
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
|
||||
});
|
||||
Self {
|
||||
pack_lights,
|
||||
..self
|
||||
}
|
||||
}
|
||||
/// Whether the destination offers HDR output.
|
||||
pub fn hdr_offered(&self) -> bool {
|
||||
self.offered.is_some()
|
||||
@@ -273,6 +291,7 @@ pub(crate) fn resolve_output(
|
||||
],
|
||||
ui_brightness: settings.ui_nits(reading.luminance) as f32
|
||||
/ settings.paper_white_nits(reading.luminance) as f32,
|
||||
pack_lights: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
|
||||
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
|
||||
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
|
||||
//! extra, docs/layers.md), the device is opened exactly as before.
|
||||
|
||||
/// The ray query feature.
|
||||
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
|
||||
|
||||
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
|
||||
fn requested() -> bool {
|
||||
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
|
||||
}
|
||||
|
||||
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
|
||||
/// build.
|
||||
fn asked(var: Option<&str>) -> bool {
|
||||
cfg!(feature = "horizon-extras") && var == Some("1")
|
||||
}
|
||||
|
||||
/// The features to request on `adapter`: ray queries, when asked for and offered.
|
||||
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
|
||||
let features = pick(requested(), adapter.features());
|
||||
if requested() {
|
||||
let info = adapter.get_info();
|
||||
if features.is_empty() {
|
||||
log::warn!(
|
||||
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
|
||||
queries; it stays off",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Horizon renderer: ray queries on ({} on {:?})",
|
||||
info.name,
|
||||
info.backend
|
||||
);
|
||||
}
|
||||
}
|
||||
features
|
||||
}
|
||||
|
||||
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
|
||||
if requested {
|
||||
offered & RAY_QUERY
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// The device's opt-in to `features`' experimental ones.
|
||||
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
|
||||
if features.contains(RAY_QUERY) {
|
||||
horizon_hdr::experimental_features()
|
||||
} else {
|
||||
wgpu::ExperimentalFeatures::disabled()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_an_extras_build_asks_for_ray_tracing() {
|
||||
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
|
||||
assert!(!asked(None));
|
||||
assert!(!asked(Some("0")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ray_queries_only_when_asked_and_offered() {
|
||||
let all = wgpu::Features::all();
|
||||
assert_eq!(pick(true, all), RAY_QUERY);
|
||||
assert!(pick(false, all).is_empty());
|
||||
assert!(pick(true, all - RAY_QUERY).is_empty());
|
||||
assert!(!experimental(wgpu::Features::empty()).is_enabled());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
|
||||
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
|
||||
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
|
||||
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
|
||||
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
|
||||
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// What a frame's readback holds and where its PNG goes.
|
||||
pub(super) struct Shot {
|
||||
pub path: PathBuf,
|
||||
/// `ScreenshotRequest::size`: the world icon's square.
|
||||
pub size: Option<[u32; 2]>,
|
||||
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
|
||||
pub frame: [u32; 2],
|
||||
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
|
||||
pub bgra: bool,
|
||||
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
|
||||
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
}
|
||||
impl Shot {
|
||||
pub fn new(
|
||||
request: &ScreenshotRequest,
|
||||
frame: [u32; 2],
|
||||
format: wgpu::TextureFormat,
|
||||
hdr: Option<(PathBuf, wgpu::Buffer)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: request.path.clone(),
|
||||
size: request.size,
|
||||
frame,
|
||||
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
|
||||
hdr,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
|
||||
pub struct Saved {
|
||||
pub path: PathBuf,
|
||||
pub result: Result<[u32; 2], String>,
|
||||
pub hdr: Option<PathBuf>,
|
||||
}
|
||||
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
|
||||
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
|
||||
let (sender, receiver) = channel();
|
||||
map(buffer, &sender);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &sender);
|
||||
}
|
||||
let waited = device
|
||||
.poll(wgpu::PollType::wait_indefinitely())
|
||||
.map_err(|e| e.to_string());
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
|
||||
write(shot, buffer, mapped)
|
||||
}
|
||||
/// Screenshots being written on their thread.
|
||||
pub(super) struct Saves {
|
||||
device: wgpu::Device,
|
||||
sender: Sender<Saved>,
|
||||
receiver: Receiver<Saved>,
|
||||
in_flight: usize,
|
||||
}
|
||||
impl Saves {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
let (sender, receiver) = channel();
|
||||
Self {
|
||||
device: device.clone(),
|
||||
sender,
|
||||
receiver,
|
||||
in_flight: 0,
|
||||
}
|
||||
}
|
||||
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
|
||||
/// PNG on a thread of its own once the GPU has finished the frame.
|
||||
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
|
||||
let (mapped, on_mapped) = channel();
|
||||
map(&buffer, &mapped);
|
||||
if let Some((_, hdr)) = &shot.hdr {
|
||||
map(hdr, &mapped);
|
||||
}
|
||||
let done = self.sender.clone();
|
||||
let path = shot.path.clone();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("Screenshot writer".into())
|
||||
.spawn(move || {
|
||||
let count = 1 + usize::from(shot.hdr.is_some());
|
||||
let ready = wait_mapped(&on_mapped, count);
|
||||
let _ = done.send(write(shot, &buffer, ready));
|
||||
});
|
||||
match spawned {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
let _ = self.sender.send(Saved {
|
||||
path,
|
||||
result: Err(format!("no thread to write it: {e}")),
|
||||
hdr: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
self.in_flight += 1;
|
||||
}
|
||||
/// The screenshots written since the last call. Polls the device so a finished frame's
|
||||
/// mapping reaches its writer: the render thread never waits for it.
|
||||
pub fn finished(&mut self) -> Vec<Saved> {
|
||||
if self.in_flight == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
let saved: Vec<Saved> = self.receiver.try_iter().collect();
|
||||
self.in_flight -= saved.len();
|
||||
saved
|
||||
}
|
||||
}
|
||||
impl Drop for Saves {
|
||||
/// A screenshot taken right before quitting is still written whole.
|
||||
fn drop(&mut self) {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while self.in_flight > 0 && std::time::Instant::now() < deadline {
|
||||
let _ = self.device.poll(wgpu::PollType::Poll);
|
||||
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
|
||||
log_saved(&saved);
|
||||
self.in_flight -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn log_saved(saved: &Saved) {
|
||||
match &saved.result {
|
||||
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
|
||||
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
|
||||
}
|
||||
}
|
||||
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
|
||||
let mapped = mapped.clone();
|
||||
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = mapped.send(r.map_err(|e| e.to_string()));
|
||||
});
|
||||
}
|
||||
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
|
||||
/// (a lost device) gives up after a while instead of keeping its thread.
|
||||
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
|
||||
for _ in 0..count {
|
||||
mapped
|
||||
.recv_timeout(Duration::from_secs(30))
|
||||
.map_err(|_| "the frame never came back from the GPU".to_string())??;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
|
||||
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
|
||||
let mut hdr_written = None;
|
||||
let result = mapped.and_then(|()| {
|
||||
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
|
||||
let pixels = read_rows(hdr_buffer, shot.frame);
|
||||
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
|
||||
Ok(()) => {
|
||||
log::info!("Saved HDR screenshot to {}", hdr_path.display());
|
||||
hdr_written = Some(hdr_path.clone());
|
||||
}
|
||||
Err(e) => log::warn!(
|
||||
"Horizon renderer: HDR screenshot {}: {e}",
|
||||
hdr_path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
let pixels = read_rows(buffer, shot.frame)?;
|
||||
write_png(&shot, &pixels)
|
||||
});
|
||||
Saved {
|
||||
path: shot.path,
|
||||
result,
|
||||
hdr: hdr_written,
|
||||
}
|
||||
}
|
||||
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
|
||||
/// row; unmaps it.
|
||||
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
|
||||
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
|
||||
let mut pixels = Vec::with_capacity(width * height);
|
||||
{
|
||||
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
|
||||
for y in 0..height {
|
||||
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
|
||||
}
|
||||
}
|
||||
buffer.unmap();
|
||||
Ok(pixels)
|
||||
}
|
||||
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
|
||||
let [frame_w, frame_h] = shot.frame;
|
||||
let mut pixels = Vec::with_capacity(frame.len() * 4);
|
||||
for px in frame {
|
||||
let (r, b) = if shot.bgra {
|
||||
(px[2], px[0])
|
||||
} else {
|
||||
(px[0], px[2])
|
||||
};
|
||||
pixels.extend_from_slice(&[r, px[1], b, 255]);
|
||||
}
|
||||
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
|
||||
// is the frame's centre square averaged down to its size.
|
||||
let (width, height, pixels) = match shot.size {
|
||||
Some(size) => (
|
||||
size[0],
|
||||
size[1],
|
||||
square_downscale(&pixels, frame_w, frame_h, size),
|
||||
),
|
||||
None => (frame_w, frame_h, pixels),
|
||||
};
|
||||
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
|
||||
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.and_then(|mut w| w.write_image_data(&pixels))
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the content light levels its pixels hold (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_png(
|
||||
path: &std::path::Path,
|
||||
[width, height]: [u32; 2],
|
||||
frame: &[[u8; 4]],
|
||||
) -> Result<(), String> {
|
||||
let pixels = hdr10_to_rgb16(frame);
|
||||
let mut info = png::Info::with_size(width, height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
|
||||
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
|
||||
.and_then(|encoder| encoder.write_header())
|
||||
.and_then(|mut writer| {
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
|
||||
writer.write_image_data(&pixels)
|
||||
});
|
||||
written.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_background_save_writes_the_png_off_the_render_thread() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("shot.png");
|
||||
let [w, h] = [3u32, 2];
|
||||
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
|
||||
let row = padded_row(w) as usize;
|
||||
let mut bytes = vec![0u8; row * h as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
|
||||
}
|
||||
}
|
||||
let source = wgpu::util::DeviceExt::create_buffer_init(
|
||||
&device,
|
||||
&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::COPY_SRC,
|
||||
},
|
||||
);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: bytes.len() as u64,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
|
||||
queue.submit([encoder.finish()]);
|
||||
let request = ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: true,
|
||||
};
|
||||
let mut saves = Saves::new(&device);
|
||||
saves.start(
|
||||
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
|
||||
buffer,
|
||||
);
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
let saved = loop {
|
||||
if let Some(saved) = saves.finished().pop() {
|
||||
break saved;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "never written");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(saved.path, path);
|
||||
assert_eq!(saved.result, Ok([w, h]));
|
||||
let decoder =
|
||||
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
|
||||
let mut reader = decoder.read_info().unwrap();
|
||||
let mut out = vec![0; reader.output_buffer_size().unwrap()];
|
||||
reader.next_frame(&mut out).unwrap();
|
||||
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
|
||||
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
|
||||
timing,
|
||||
};
|
||||
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
|
||||
#[path = "ray_query.rs"]
|
||||
mod ray_query;
|
||||
use std::sync::Arc;
|
||||
use winit::window::Window;
|
||||
|
||||
@@ -134,6 +137,7 @@ pub(super) async fn open_adapter(
|
||||
let features = timing::GpuTimer::features(adapter)
|
||||
| cull::Cull::features(adapter)
|
||||
| oit::features(adapter)
|
||||
| ray_query::features(adapter)
|
||||
| (adapter.features()
|
||||
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
|
||||
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
|
||||
@@ -166,6 +170,7 @@ pub(super) async fn open_adapter(
|
||||
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
|
||||
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
|
||||
memory_hints: wgpu::MemoryHints::MemoryUsage,
|
||||
experimental_features: ray_query::experimental(features),
|
||||
..Default::default()
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
//! colours and the controls it draws with. The screen (`graphics_ui`) only
|
||||
//! lays out rows and handles input; a menu theme replaces this module.
|
||||
//!
|
||||
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
|
||||
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
|
||||
//! with a thin edge, so the world shows through, flat controls, and the violet
|
||||
//! face on the one main button, in vanilla's font.
|
||||
//! Its colours come from `menu_style`, given at startup (in the official game the design
|
||||
//! system's Blocks palette, `docs/design-system.md`), in a lighter form: square panels of
|
||||
//! dark glass with a thin edge, so the world shows through, flat controls, and the face on
|
||||
//! the one main button, in vanilla's font.
|
||||
use crate::menu_style::{self, SettingsColours};
|
||||
use crate::{
|
||||
hud::{HudAtlas, HudBatch},
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_theme::BLOCKS_DARK as B;
|
||||
|
||||
/// The settings pages' colours (`menu_style`, given at startup).
|
||||
fn b() -> &'static SettingsColours {
|
||||
&menu_style::get().settings
|
||||
}
|
||||
|
||||
/// What a setting costs in frame time, shown with its description.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -36,54 +41,91 @@ impl Cost {
|
||||
/// `0xRRGGBB`: the palette's success, warn and danger.
|
||||
pub fn rgb(self) -> u32 {
|
||||
match self {
|
||||
Cost::None => B.ink_soft,
|
||||
Cost::Low | Cost::Saves => B.success,
|
||||
Cost::Medium => B.warn,
|
||||
Cost::High => B.danger,
|
||||
Cost::None => b().ink_soft,
|
||||
Cost::Low | Cost::Saves => b().success,
|
||||
Cost::Medium => b().warn,
|
||||
Cost::High => b().danger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
|
||||
const fn hex(c: u32, a: u8) -> u32 {
|
||||
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
|
||||
}
|
||||
use menu_style::hud_colour as hex;
|
||||
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
|
||||
const GLASS: u32 = hex(B.page, 118);
|
||||
fn glass() -> u32 {
|
||||
hex(b().page, 118)
|
||||
}
|
||||
/// Their 1 px edge.
|
||||
const EDGE: u32 = hex(B.ink, 26);
|
||||
pub fn edge() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// A group's panel, a little lighter than the plate it sits on.
|
||||
const GROUP: u32 = hex(B.ink, 11);
|
||||
fn group_fill() -> u32 {
|
||||
hex(b().ink, 11)
|
||||
}
|
||||
/// A hovered or focused row, lit on its group's panel.
|
||||
pub const HOVER: u32 = hex(B.ink, 18);
|
||||
pub fn hover() -> u32 {
|
||||
hex(b().ink, 18)
|
||||
}
|
||||
/// The open page in the page list.
|
||||
pub const OPEN: u32 = hex(B.ink, 26);
|
||||
pub fn open() -> u32 {
|
||||
hex(b().ink, 26)
|
||||
}
|
||||
/// The rule under the plate's heading, and between two rows of a group.
|
||||
pub const HEAD_RULE: u32 = hex(B.ink, 31);
|
||||
pub const ROW_RULE: u32 = hex(B.ink, 15);
|
||||
pub fn head_rule() -> u32 {
|
||||
hex(b().ink, 31)
|
||||
}
|
||||
pub fn row_rule() -> u32 {
|
||||
hex(b().ink, 15)
|
||||
}
|
||||
/// Fields, boxes and the value stepper: sunk, see-through.
|
||||
const WELL: u32 = hex(B.outline, 102);
|
||||
fn well_fill() -> u32 {
|
||||
hex(b().outline, 102)
|
||||
}
|
||||
/// A button, a slider's track, the scroller's thumb.
|
||||
const BUTTON: u32 = hex(B.ink, 20);
|
||||
const BUTTON_HOVER: u32 = hex(B.ink, 41);
|
||||
const TRACK: u32 = hex(B.ink, 64);
|
||||
const HANDLE: u32 = hex(B.ink, 255);
|
||||
fn button_fill() -> u32 {
|
||||
hex(b().ink, 20)
|
||||
}
|
||||
fn button_hover() -> u32 {
|
||||
hex(b().ink, 41)
|
||||
}
|
||||
fn track_fill() -> u32 {
|
||||
hex(b().ink, 64)
|
||||
}
|
||||
fn handle_fill() -> u32 {
|
||||
hex(b().ink, 255)
|
||||
}
|
||||
/// The one main button.
|
||||
const FACE: u32 = hex(B.face, 255);
|
||||
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
|
||||
pub fn face() -> u32 {
|
||||
hex(b().face, 255)
|
||||
}
|
||||
fn face_hover() -> u32 {
|
||||
hex(b().face_hover, 255)
|
||||
}
|
||||
/// A tick box's edge when off.
|
||||
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
|
||||
const OUTLINE: u32 = hex(B.outline, 255);
|
||||
pub const ACCENT: u32 = hex(B.accent, 255);
|
||||
fn box_edge() -> u32 {
|
||||
hex(b().ink_soft, 89)
|
||||
}
|
||||
fn outline() -> u32 {
|
||||
hex(b().outline, 255)
|
||||
}
|
||||
pub fn accent() -> u32 {
|
||||
hex(b().accent, 255)
|
||||
}
|
||||
/// Vanilla's white text, and its grey for what can't be used now.
|
||||
pub const WHITE: u32 = u32::MAX;
|
||||
pub const MUTED: u32 = hex(B.muted, 255);
|
||||
pub fn muted() -> u32 {
|
||||
hex(b().muted, 255)
|
||||
}
|
||||
/// A page that isn't open, a value at rest.
|
||||
pub const SOFT: u32 = hex(B.ink_soft, 255);
|
||||
pub fn soft() -> u32 {
|
||||
hex(b().ink_soft, 255)
|
||||
}
|
||||
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
|
||||
pub const GREY: u32 = hex(0xaaaaaa, 255);
|
||||
/// A light veil over the backdrop, under the panels.
|
||||
pub const VEIL: u32 = hex(B.page, 77);
|
||||
pub fn veil() -> u32 {
|
||||
hex(b().page, 77)
|
||||
}
|
||||
|
||||
/// Sizes in GUI pixels.
|
||||
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
|
||||
@@ -120,21 +162,21 @@ pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], colo
|
||||
}
|
||||
/// The screen's plate, and the strip under it: dark glass with a thin edge.
|
||||
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GLASS);
|
||||
ring(batch, atlas, rect, EDGE);
|
||||
fill(batch, atlas, rect, glass());
|
||||
ring(batch, atlas, rect, edge());
|
||||
}
|
||||
/// A group's panel on the plate, its rows on it.
|
||||
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, GROUP);
|
||||
fill(batch, atlas, rect, group_fill());
|
||||
}
|
||||
/// A sunk well: the search field, the value stepper.
|
||||
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
}
|
||||
/// The scroller: a faint track and its thumb.
|
||||
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
|
||||
fill(batch, atlas, track, ROW_RULE);
|
||||
fill(batch, atlas, thumb, TRACK);
|
||||
fill(batch, atlas, track, row_rule());
|
||||
fill(batch, atlas, thumb, track_fill());
|
||||
}
|
||||
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control: the HUD
|
||||
/// kit's `fit::ellipsize`.
|
||||
@@ -142,11 +184,11 @@ pub use crate::hud_kit::fit::ellipsize as fit;
|
||||
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
|
||||
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
|
||||
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
|
||||
fill(batch, atlas, rect, WELL);
|
||||
fill(batch, atlas, rect, well_fill());
|
||||
let edge = match (on, live) {
|
||||
(true, true) => ACCENT,
|
||||
(true, false) => MUTED,
|
||||
(false, _) => BOX_EDGE,
|
||||
(true, true) => accent(),
|
||||
(true, false) => muted(),
|
||||
(false, _) => box_edge(),
|
||||
};
|
||||
ring(batch, atlas, rect, edge);
|
||||
if on {
|
||||
@@ -169,7 +211,7 @@ fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
|
||||
for (i, c) in row.chars().enumerate() {
|
||||
if c == '#' {
|
||||
let (px, py) = (x + i as f32, y + j as f32);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
|
||||
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], outline());
|
||||
fill(batch, atlas, [px, py, 1.0, 1.0], color);
|
||||
}
|
||||
}
|
||||
@@ -198,17 +240,17 @@ pub fn button_with_room(
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let face = match (live, primary, hover) {
|
||||
(false, _, _) => WELL,
|
||||
(true, true, false) => FACE,
|
||||
(true, true, true) => FACE_HOVER,
|
||||
(true, false, false) => BUTTON,
|
||||
(true, false, true) => BUTTON_HOVER,
|
||||
(false, _, _) => well_fill(),
|
||||
(true, true, false) => face(),
|
||||
(true, true, true) => face_hover(),
|
||||
(true, false, false) => button_fill(),
|
||||
(true, false, true) => button_hover(),
|
||||
};
|
||||
fill(batch, atlas, rect, face);
|
||||
if hover && live {
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let left = x + 2.0 + room;
|
||||
scrolling_label(
|
||||
@@ -252,7 +294,7 @@ pub fn slider(
|
||||
(hover, live): (bool, bool),
|
||||
) {
|
||||
let [x, y, w, h] = rect;
|
||||
let color = if live { WHITE } else { MUTED };
|
||||
let color = if live { WHITE } else { muted() };
|
||||
scrolling_label(
|
||||
batch,
|
||||
atlas,
|
||||
@@ -263,17 +305,22 @@ pub fn slider(
|
||||
);
|
||||
let line = y + h - 3.0;
|
||||
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
|
||||
fill(batch, atlas, [x, line, w, 2.0], TRACK);
|
||||
fill(batch, atlas, [x, line, w, 2.0], track_fill());
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
[x, line, at - x, 2.0],
|
||||
if live { ACCENT } else { MUTED },
|
||||
if live { accent() } else { muted() },
|
||||
);
|
||||
let handle = [at, line - 3.0, 4.0, 8.0];
|
||||
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
|
||||
fill(
|
||||
batch,
|
||||
atlas,
|
||||
handle,
|
||||
if live { handle_fill() } else { muted() },
|
||||
);
|
||||
if hover && live {
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
|
||||
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], accent());
|
||||
}
|
||||
}
|
||||
/// A value that a press steps through: in a well, between the arrows that say so.
|
||||
@@ -290,13 +337,13 @@ pub fn cycler(
|
||||
ring(batch, atlas, rect, WHITE);
|
||||
}
|
||||
let ty = y + ((h - 8.0) / 2.0).floor();
|
||||
let arrows = if hover && live { WHITE } else { MUTED };
|
||||
let arrows = if hover && live { WHITE } else { muted() };
|
||||
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
|
||||
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
|
||||
let color = match (live, hover) {
|
||||
(false, _) => MUTED,
|
||||
(false, _) => muted(),
|
||||
(true, true) => WHITE,
|
||||
(true, false) => SOFT,
|
||||
(true, false) => soft(),
|
||||
};
|
||||
scrolling_label(batch, atlas, value, [x + 9.0, ty, w - 18.0], (y, h), color);
|
||||
}
|
||||
|
||||
@@ -1110,7 +1110,8 @@ impl Item {
|
||||
/// active; saved values wait for it. Translucent Batching switches where the device can batch
|
||||
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
|
||||
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
|
||||
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
|
||||
/// Lighting has nothing to switch where the running shader pack lights the world itself.
|
||||
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
|
||||
if !self.available() {
|
||||
return Some("This feature is not yet available in Horizon.");
|
||||
@@ -1141,6 +1142,9 @@ impl Item {
|
||||
} else {
|
||||
"Turn HDR on to use it."
|
||||
}),
|
||||
Item::Horizon(H::HdrLighting) if out.pack_lights => {
|
||||
Some("The shader pack lights the world itself.")
|
||||
}
|
||||
Item::Horizon(H::TranslucentBatching)
|
||||
if !crate::graphics::translucent_batching_offered() =>
|
||||
{
|
||||
|
||||
@@ -31,6 +31,49 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
|
||||
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
|
||||
use horizon_shader_api::Api;
|
||||
let mut s = Settings::default();
|
||||
s.horizon.hdr = HdrMode::On;
|
||||
let item = Item::Horizon(H::HdrLighting);
|
||||
let lights = Some("The shader pack lights the world itself.");
|
||||
let out = offering(true, None);
|
||||
// No pack running, as with one picked but refused, missing or its mod off: live.
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
|
||||
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
|
||||
lights
|
||||
);
|
||||
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
|
||||
let float = EffectiveOutput {
|
||||
frame: crate::gfx::FrameConfiguration {
|
||||
format: wgpu::TextureFormat::Rgba16Float,
|
||||
..out.frame
|
||||
},
|
||||
..out
|
||||
};
|
||||
assert_eq!(
|
||||
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
|
||||
lights
|
||||
);
|
||||
// HDR's own reasons come first.
|
||||
assert_eq!(
|
||||
item.blocked(&s, &NONE.with_pack(true, None)),
|
||||
Some("Your display or system doesn't offer HDR output.")
|
||||
);
|
||||
s.horizon.hdr = HdrMode::Off;
|
||||
assert_eq!(
|
||||
item.blocked(&s, &out.with_pack(true, None)),
|
||||
Some("Turn HDR on to use it.")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
|
||||
let mut s = Settings::default();
|
||||
|
||||
@@ -111,11 +111,11 @@ pub struct HudAtlas {
|
||||
/// The packs' Polytone screen sprite modifiers.
|
||||
pub sprite_modifiers: std::sync::Arc<sprite_modifiers::SpriteModifiers>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
pub random_textures: crate::entity_textures::RandomTextures,
|
||||
/// Their emissive overlays (`_e`), packed here too.
|
||||
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
|
||||
pub emissive: std::sync::Arc<crate::entity_textures::Emissive>,
|
||||
/// ETF's blinking frames (`_blink`), packed here too.
|
||||
pub blinks: horizon_optifine::blink::Blinks,
|
||||
pub blinks: crate::entity_textures::Blinks,
|
||||
/// Of the shared atlas's textures, their LabPBR normal and specular maps as paged maps
|
||||
/// (`paged_maps.rs`), read while loading for shader packs (`sprites::labpbr_maps`).
|
||||
pub material_maps: std::sync::Arc<[Option<crate::paged_maps::PagedMap>; 2]>,
|
||||
@@ -433,7 +433,7 @@ fn cit_sprites(
|
||||
packed: &[PackedImage],
|
||||
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
|
||||
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let rules = crate::cit::load_rules(root);
|
||||
let rules = crate::cit::CitRules::load(root).rules;
|
||||
let mut ids = crate::cit_assets::item_sprites(&rules);
|
||||
if !rules.is_empty() {
|
||||
let models = crate::cit_assets::load_models(root, &rules);
|
||||
@@ -1204,25 +1204,18 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy().replace('\\', "/");
|
||||
!s.contains("title/background") && !s.contains("presets")
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
let random_textures = if folders.contains(&"entity") {
|
||||
horizon_optifine::random_textures::RandomTextures::scan(root)
|
||||
// Random entity variants, under the names their scan gives them (those outside
|
||||
// `textures/` are `<ns>:optifine/random/...`), blink frames and emissive overlays.
|
||||
let crate::entity_textures::EntityTextures {
|
||||
random: random_textures,
|
||||
blinks,
|
||||
emissive,
|
||||
} = if folders.contains(&"entity") {
|
||||
crate::entity_textures::EntityTextures::load(root)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let blinks = if folders.contains(&"entity") {
|
||||
horizon_optifine::blink::Blinks::scan(root, &random_textures)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let emissive = std::sync::Arc::new(if folders.contains(&"entity") {
|
||||
let mut e = horizon_optifine::emissive::Emissive::scan(root, &random_textures);
|
||||
e.pair_beside(root, blinks.files());
|
||||
e
|
||||
} else {
|
||||
Default::default()
|
||||
});
|
||||
let emissive = std::sync::Arc::new(emissive);
|
||||
let mut named: HashMap<PathBuf, String> = HashMap::new();
|
||||
let extra = random_textures
|
||||
.files()
|
||||
@@ -3474,6 +3467,7 @@ mod tests {
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
fn random_entity_variants_pack_under_their_scan_names() {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-random-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3496,6 +3490,7 @@ mod tests {
|
||||
/// A random variant's emissive overlay packs beside the variant, under its name.
|
||||
#[test]
|
||||
fn variant_overlays_pack_under_their_names() {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-overlay-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3810,6 +3805,7 @@ mod tests {
|
||||
}
|
||||
/// A resource root with one plain item texture and these CIT files (relative path, contents).
|
||||
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-cit-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3899,7 +3895,7 @@ mod tests {
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
|
||||
let rules = crate::cit::load_rules(&root);
|
||||
let rules = crate::cit::CitRules::load(&root).rules;
|
||||
assert_eq!(rules.len(), 1);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
|
||||
//! Custom item texture enchantment glints on GUI items (`Kind::Enchantment`, see `crate::cit_glint` for
|
||||
//! which layers a stack draws): every rule's texture in one texture array, each layer an extra
|
||||
//! pass over the item's quads with its rule's blend. A layer's vertices carry its texture slot
|
||||
//! (red) and its intensity (alpha) in their colour; its scrolled UV is worked out on the CPU, as
|
||||
//! vanilla's glint's is. Without enchantment rules nothing here is made.
|
||||
|
||||
use super::HudVertex;
|
||||
use crate::cit::{Blend, Factor, Kind, Rule};
|
||||
use crate::gfx::hud_coverage::Pipeline;
|
||||
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
|
||||
use std::path::Path;
|
||||
|
||||
/// Slots the shader's table holds; rules past it draw no layer (and match no stack).
|
||||
@@ -438,13 +438,11 @@ impl CitGlintPass {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
use crate::pack_formats::cit::{parse_blend, parse_rule};
|
||||
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
|
||||
let text = format!("texture=glint\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
parse_rule(&path, &format!("texture=glint\n{text}")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -338,7 +338,7 @@ fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
use crate::pack_formats::cit::parse_blend;
|
||||
for (name, expected) in [
|
||||
("alpha", 128),
|
||||
("add", 0),
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! where a tween is at the frame's [`Clock`]; nothing here holds state or runs on its own, and
|
||||
//! no motion ever decides what input does.
|
||||
//!
|
||||
//! The curves are the design system's (`horizon_theme::QUICK`, `MOVE`): a [`Tween`] takes one
|
||||
//! of them, at the theme's duration or a shorter one of its own.
|
||||
//! A [`Tween`] takes a duration and a CSS `cubic-bezier` curve: Horizon's overlays use the design
|
||||
//! system's (`horizon_theme::QUICK`, `MOVE`), at the theme's duration or a shorter one of their
|
||||
//! own.
|
||||
//!
|
||||
//! The game builds the clock once a frame (`Clock::new(now, still)`). A still clock (the game is
|
||||
//! paused, frame-stepped by `HORIZON_STILL` or a screenshot sequence, or a repro recording or
|
||||
@@ -16,7 +17,7 @@
|
||||
//! use std::time::{Duration, Instant};
|
||||
//!
|
||||
//! let opened = Instant::now();
|
||||
//! let open = Tween::new(Duration::from_millis(90), horizon_theme::QUICK);
|
||||
//! let open = Tween::new(Duration::from_millis(90), [0.4, 0.0, 0.2, 1.0]);
|
||||
//! // Half way through: partly faded in, partly grown from 94%.
|
||||
//! let clock = Clock::new(opened + Duration::from_millis(45), false);
|
||||
//! let alpha = motion::fade(open, clock, opened);
|
||||
@@ -26,7 +27,6 @@
|
||||
//! let still = Clock::new(opened, true);
|
||||
//! assert_eq!(motion::fade(open, still, opened), 1.0);
|
||||
//! ```
|
||||
use horizon_theme::Motion;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The time a frame shows, built once a frame by the game.
|
||||
@@ -50,28 +50,20 @@ impl Clock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration and one of the design system's easing curves.
|
||||
/// A duration and an easing curve.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Tween {
|
||||
/// How long it runs.
|
||||
pub duration: Duration,
|
||||
/// The CSS `cubic-bezier` control points of its curve (a `horizon_theme::Motion`'s).
|
||||
/// The CSS `cubic-bezier` control points of its curve.
|
||||
pub curve: [f32; 4],
|
||||
}
|
||||
|
||||
impl Tween {
|
||||
/// `motion`'s curve over `duration`: the dial's motions are shorter than the launcher's, on
|
||||
/// the same curves.
|
||||
pub const fn new(duration: Duration, motion: Motion) -> Self {
|
||||
Self {
|
||||
duration,
|
||||
curve: motion.curve,
|
||||
}
|
||||
}
|
||||
|
||||
/// A theme motion as it is: its own duration and curve.
|
||||
pub const fn of(motion: Motion) -> Self {
|
||||
Self::new(motion.duration(), motion)
|
||||
/// `curve` over `duration`: the dial's motions are shorter than the launcher's, on the same
|
||||
/// curves.
|
||||
pub const fn new(duration: Duration, curve: [f32; 4]) -> Self {
|
||||
Self { duration, curve }
|
||||
}
|
||||
|
||||
/// The linear progress, 0 to 1, `since` a start at this clock; 1 on a still clock.
|
||||
@@ -84,7 +76,7 @@ impl Tween {
|
||||
|
||||
/// The eased progress, 0 to 1, on the tween's curve.
|
||||
pub fn progress(self, clock: Clock, since: Instant) -> f32 {
|
||||
horizon_theme::bezier(self.curve, self.linear(clock, since))
|
||||
bezier(self.curve, self.linear(clock, since))
|
||||
}
|
||||
|
||||
/// Whether it has finished (always, on a still clock).
|
||||
@@ -98,6 +90,29 @@ impl Tween {
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS `cubic-bezier(x1, y1, x2, y2)` at `t` in 0..1: solves x for the curve parameter,
|
||||
/// returns y.
|
||||
pub fn bezier([x1, y1, x2, y2]: [f32; 4], t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t == 0.0 || t == 1.0 {
|
||||
return t;
|
||||
}
|
||||
let at = |a: f32, b: f32, s: f32| {
|
||||
let u = 1.0 - s;
|
||||
3.0 * u * u * s * a + 3.0 * u * s * s * b + s * s * s
|
||||
};
|
||||
let (mut lo, mut hi) = (0f32, 1f32);
|
||||
for _ in 0..24 {
|
||||
let mid = (lo + hi) / 2.0;
|
||||
if at(x1, x2, mid) < t {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
}
|
||||
at(y1, y2, (lo + hi) / 2.0)
|
||||
}
|
||||
|
||||
/// An alpha from 0 to 1 as something fades in.
|
||||
pub fn fade(tween: Tween, clock: Clock, since: Instant) -> f32 {
|
||||
tween.progress(clock, since)
|
||||
@@ -145,7 +160,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_tween_runs_from_its_start_to_its_end_and_holds() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
assert_eq!(tween.progress(at(t0, 0), t0), 0.0);
|
||||
assert_eq!(tween.progress(at(t0, 80), t0), 1.0);
|
||||
assert_eq!(tween.progress(at(t0, 500), t0), 1.0);
|
||||
@@ -160,8 +175,7 @@ mod tests {
|
||||
fn a_still_clock_shows_every_motion_settled() {
|
||||
let t0 = Instant::now();
|
||||
let still = Clock::new(t0, true);
|
||||
let tween = Tween::of(horizon_theme::QUICK);
|
||||
assert_eq!(tween.duration, horizon_theme::QUICK.duration());
|
||||
let tween = Tween::new(horizon_theme::QUICK.duration(), horizon_theme::QUICK.curve);
|
||||
assert_eq!(fade(tween, still, t0), 1.0);
|
||||
assert_eq!(fade_out(tween, still, t0), 0.0);
|
||||
assert_eq!(scale_in(tween, still, t0, 0.94), 1.0);
|
||||
@@ -173,7 +187,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_helpers_start_where_they_say() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK);
|
||||
let tween = Tween::new(MS(100), horizon_theme::QUICK.curve);
|
||||
let c = at(t0, 0);
|
||||
assert_eq!(fade(tween, c, t0), 0.0);
|
||||
assert_eq!(fade_out(tween, c, t0), 1.0);
|
||||
@@ -184,10 +198,25 @@ mod tests {
|
||||
assert!((flash(tween, at(t0, 25), t0) - 0.75).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_the_design_systems() {
|
||||
for motion in horizon_theme::MOTION {
|
||||
for i in 0..=20 {
|
||||
let t = i as f32 / 20.0;
|
||||
assert_eq!(
|
||||
bezier(motion.curve, t),
|
||||
motion.ease(t),
|
||||
"{} at {t}",
|
||||
motion.name
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_turn_takes_the_short_way_round() {
|
||||
let t0 = Instant::now();
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE);
|
||||
let tween = Tween::new(MS(80), horizon_theme::MOVE.curve);
|
||||
// From up-left (7/8 of a turn) to up-right: through up, not round the bottom.
|
||||
let from = 7.0 * FRAC_PI_4;
|
||||
let half = turn(tween, at(t0, 1), t0, from, FRAC_PI_4);
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
//! ```
|
||||
use crate::hud::{HudAtlas, HudBatch};
|
||||
use crate::hud_kit::{colour, fit};
|
||||
use crate::menu_style::{self, OverlayColours};
|
||||
|
||||
/// The overlays' colours (`menu_style`, given at startup).
|
||||
fn overlay() -> &'static OverlayColours {
|
||||
&menu_style::get().overlay
|
||||
}
|
||||
|
||||
/// A pip under the strip: its size and the step from one to the next, in GUI px.
|
||||
pub const PIP: [f32; 2] = [3.0, 2.0];
|
||||
@@ -45,8 +51,12 @@ pub const PIP_STEP: f32 = 5.0;
|
||||
/// How far, in GUI px, the arrows sit from the name.
|
||||
pub const ARROW_GAP: f32 = 4.0;
|
||||
/// An arrow with a page that way, and one at an end, with none.
|
||||
pub const ARROW: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub const ARROW_OFF: u32 = colour(horizon_theme::BACKDROP_MUTED, 77);
|
||||
pub fn arrow_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
pub fn arrow_off_colour() -> u32 {
|
||||
colour(overlay().backdrop_muted, 77)
|
||||
}
|
||||
/// The pips (the selected one is white).
|
||||
pub const PIP_OFF: u32 = colour(0xFFFFFF, 89);
|
||||
pub const PIP_ON: u32 = colour(0xFFFFFF, 255);
|
||||
@@ -65,9 +75,15 @@ pub const EDGE_FADE: f32 = 12.0;
|
||||
pub const FADE: [f32; 5] = [1.0, 0.7, 0.35, 0.15, 0.1];
|
||||
/// The selected tab's fill and text, and the others'.
|
||||
pub const SELECTED_FILL: u32 = colour(0xFFFFFF, 242);
|
||||
pub const SELECTED_TEXT: u32 = colour(horizon_theme::DARK.page, 255);
|
||||
pub const TAB_FILL: u32 = colour(horizon_theme::DARK.page, 191);
|
||||
pub const TAB_TEXT: u32 = colour(horizon_theme::BACKDROP_MUTED, 255);
|
||||
pub fn selected_text() -> u32 {
|
||||
colour(overlay().page, 255)
|
||||
}
|
||||
pub fn tab_fill() -> u32 {
|
||||
colour(overlay().page, 191)
|
||||
}
|
||||
pub fn tab_text() -> u32 {
|
||||
colour(overlay().backdrop_muted, 255)
|
||||
}
|
||||
/// The line of text's height in GUI px (vanilla's font, without its shadow).
|
||||
const LINE: f32 = 8.0;
|
||||
|
||||
@@ -144,7 +160,11 @@ pub fn draw(batch: &mut HudBatch, atlas: &HudAtlas, strip: &Strip) {
|
||||
arrow,
|
||||
ax,
|
||||
ay,
|
||||
if on { ARROW } else { ARROW_OFF },
|
||||
if on {
|
||||
arrow_colour()
|
||||
} else {
|
||||
arrow_off_colour()
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -282,9 +302,9 @@ pub fn draw_tabs(batch: &mut HudBatch, atlas: &HudAtlas, strip: &TabStrip, alpha
|
||||
for tab in &row.tabs {
|
||||
let [tx, ty, tw, th] = tab.area;
|
||||
let (fill, text) = if tab.selected {
|
||||
(SELECTED_FILL, SELECTED_TEXT)
|
||||
(SELECTED_FILL, selected_text())
|
||||
} else {
|
||||
(TAB_FILL, TAB_TEXT)
|
||||
(tab_fill(), tab_text())
|
||||
};
|
||||
let tab_start = batch.vertices.len();
|
||||
// Cut where the edges' fades start, so each fade runs over its own piece.
|
||||
@@ -380,15 +400,16 @@ mod tests {
|
||||
let [ax, _] = strip.arrows[i];
|
||||
(batch.vertices.iter())
|
||||
.filter(|v| {
|
||||
(v.pos[0] - ax).abs() < 4.0 && v.color & 0x00FF_FFFF == ARROW & 0x00FF_FFFF
|
||||
(v.pos[0] - ax).abs() < 4.0
|
||||
&& v.color & 0x00FF_FFFF == arrow_colour() & 0x00FF_FFFF
|
||||
})
|
||||
.map(|v| v.color >> 24)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let first = collapsed(&atlas, &pages, 0, AREA);
|
||||
assert_eq!(alpha(&first, 0), ARROW_OFF >> 24);
|
||||
assert_eq!(alpha(&first, 1), ARROW >> 24);
|
||||
assert_eq!(alpha(&first, 0), arrow_off_colour() >> 24);
|
||||
assert_eq!(alpha(&first, 1), arrow_colour() >> 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,7 +14,6 @@ pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod block_sprites;
|
||||
pub mod block_states;
|
||||
@@ -44,6 +43,7 @@ pub mod custom_sky;
|
||||
pub mod datapack;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod entity_textures;
|
||||
pub mod environment;
|
||||
pub mod extras;
|
||||
pub(crate) mod extras_ui;
|
||||
@@ -51,7 +51,6 @@ pub mod fast_hash;
|
||||
pub mod fluid_render;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod fusion;
|
||||
pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
@@ -74,6 +73,7 @@ pub mod light;
|
||||
pub mod lod;
|
||||
pub mod logging;
|
||||
pub mod menu_blur;
|
||||
pub mod menu_style;
|
||||
pub mod mesher;
|
||||
pub mod minimap;
|
||||
pub mod mipmap;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! The look of the screens vanilla doesn't have that the engine still draws: the settings pages
|
||||
//! (`graphics_style`), the HUD kit's tab strip, and the overlays drawn with `ui_theme` (the
|
||||
//! on-screen keyboard, the boot screen, the missing-resources window). Vanilla's screens never
|
||||
//! read it.
|
||||
//!
|
||||
//! The layer above gives it once at startup ([`install`]), before the first frame; the
|
||||
//! official game gives Horizon's design system (`docs/design-system.md`). With none given, the
|
||||
//! screens draw in [`NEUTRAL`]: vanilla's greys and chat colours, and no text where they would
|
||||
//! use their own fonts. A read is one atomic load, and only those screens make it.
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Colours as `0xRRGGBB`, sizes in dp (the launcher's logical pixels).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct MenuStyle {
|
||||
pub settings: SettingsColours,
|
||||
pub overlay: OverlayColours,
|
||||
pub sizes: Sizes,
|
||||
/// The overlays' TrueType faces; none: they draw no text.
|
||||
pub fonts: Option<Fonts>,
|
||||
}
|
||||
|
||||
/// The settings pages: square panels of dark glass over the world, flat controls, one face.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SettingsColours {
|
||||
/// The plate behind everything, drawn see-through.
|
||||
pub page: u32,
|
||||
/// Text, and the light the panels, rules, hovers and controls are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// Labels, descriptions, what can't be used.
|
||||
pub muted: u32,
|
||||
/// Wells and the dark edge of small marks.
|
||||
pub outline: u32,
|
||||
/// Focus, a checked box, a slider's fill.
|
||||
pub accent: u32,
|
||||
/// The one main button, and while hovered.
|
||||
pub face: u32,
|
||||
pub face_hover: u32,
|
||||
pub success: u32,
|
||||
pub warn: u32,
|
||||
pub danger: u32,
|
||||
}
|
||||
|
||||
/// Overlays drawn over the game (the HUD kit's tab strip, `ui_theme`'s overlays).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OverlayColours {
|
||||
/// The dark page under text and tabs.
|
||||
pub page: u32,
|
||||
/// Muted text over a veiled backdrop.
|
||||
pub backdrop_muted: u32,
|
||||
/// Text and the light hover and pressed plates are drawn in.
|
||||
pub ink: u32,
|
||||
/// Secondary text and values.
|
||||
pub ink_soft: u32,
|
||||
/// One-pixel rules between rows.
|
||||
pub rule: u32,
|
||||
/// The main action, and its text.
|
||||
pub accent: u32,
|
||||
pub on_accent: u32,
|
||||
}
|
||||
|
||||
/// `ui_theme`'s type scale and shapes, in dp.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Sizes {
|
||||
pub caption: f32,
|
||||
pub label: f32,
|
||||
pub ui: f32,
|
||||
pub body: f32,
|
||||
pub title: f32,
|
||||
/// The boot screen's wordmark at its smallest, and its console lines.
|
||||
pub display: f32,
|
||||
pub micro: f32,
|
||||
/// Fields; plates (hover rows, selection); a large control's height.
|
||||
pub radius: f32,
|
||||
pub radius_plate: f32,
|
||||
pub control_large: f32,
|
||||
}
|
||||
|
||||
/// TrueType faces (`ui_theme::Face`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Fonts {
|
||||
pub regular: &'static [u8],
|
||||
pub medium: &'static [u8],
|
||||
pub semibold: &'static [u8],
|
||||
pub mono: &'static [u8],
|
||||
}
|
||||
|
||||
/// The look with nothing given: vanilla's greys and chat colours.
|
||||
pub const NEUTRAL: MenuStyle = MenuStyle {
|
||||
settings: SettingsColours {
|
||||
page: 0x101010,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
muted: 0xa0a0a0,
|
||||
outline: 0x000000,
|
||||
accent: 0xffffff,
|
||||
face: 0x555555,
|
||||
face_hover: 0x6f6f6f,
|
||||
success: 0x55ff55,
|
||||
warn: 0xffff55,
|
||||
danger: 0xff5555,
|
||||
},
|
||||
overlay: OverlayColours {
|
||||
page: 0x101010,
|
||||
backdrop_muted: 0xa0a0a0,
|
||||
ink: 0xffffff,
|
||||
ink_soft: 0xe0e0e0,
|
||||
rule: 0x303030,
|
||||
accent: 0xffffff,
|
||||
on_accent: 0x000000,
|
||||
},
|
||||
sizes: Sizes {
|
||||
caption: 12.0,
|
||||
label: 13.0,
|
||||
ui: 14.0,
|
||||
body: 15.0,
|
||||
title: 28.0,
|
||||
display: 44.0,
|
||||
micro: 11.0,
|
||||
radius: 0.0,
|
||||
radius_plate: 0.0,
|
||||
control_large: 40.0,
|
||||
},
|
||||
fonts: None,
|
||||
};
|
||||
|
||||
static STYLE: OnceLock<MenuStyle> = OnceLock::new();
|
||||
|
||||
/// Give the menus their look: call it once at startup. A second call keeps the first look and
|
||||
/// returns false.
|
||||
pub fn install(style: MenuStyle) -> bool {
|
||||
STYLE.set(style).is_ok()
|
||||
}
|
||||
|
||||
/// The look given to [`install`], or [`NEUTRAL`].
|
||||
#[inline]
|
||||
pub fn get() -> &'static MenuStyle {
|
||||
STYLE.get().unwrap_or(&NEUTRAL)
|
||||
}
|
||||
|
||||
/// `0xRRGGBB` and an alpha in the HUD's colour encoding (`0xAABBGGRR`).
|
||||
pub const fn hud_colour(rgb: u32, alpha: u8) -> u32 {
|
||||
(alpha as u32) << 24 | (rgb & 0xff) << 16 | (rgb >> 8 & 0xff) << 8 | (rgb >> 16 & 0xff)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ mod sides;
|
||||
#[cfg(test)]
|
||||
#[path = "mesher/source_tests.rs"]
|
||||
mod source_tests;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::connected::{ConnectedTextures, NoConnected, Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::{Biomes, GrassModifier},
|
||||
@@ -24,7 +24,6 @@ use horizon_assets::model::Direction as ModelDirection;
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
|
||||
use horizon_world::{
|
||||
block_render::FaceVisibility,
|
||||
fluid::{FluidKind, FluidState},
|
||||
@@ -1872,11 +1871,27 @@ impl Mesher<'_> {
|
||||
)
|
||||
}
|
||||
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
|
||||
/// rather than allocated per section.
|
||||
/// rather than allocated per section. A pack's connected textures mesh through their
|
||||
/// format's entry point ([`crate::connected::Connected::mesh`]).
|
||||
pub fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
let render = self.render;
|
||||
match &render.connected {
|
||||
Some(connected) => (connected.mesh)(self, &*connected.rules, region, sy, out, scratch),
|
||||
None => self.mesh_with(&NoConnected, region, sy, out, scratch),
|
||||
}
|
||||
}
|
||||
/// [`Self::mesh_reusing`] with the connected textures `connected`.
|
||||
pub fn mesh_with<C: ConnectedTextures>(
|
||||
&mut self,
|
||||
connected: &C,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
mut out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
@@ -2021,7 +2036,6 @@ impl Mesher<'_> {
|
||||
)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
let connected = &self.render.connected;
|
||||
let start = [out.solid.len(), out.translucent.len()];
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
@@ -2052,6 +2066,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
self.face(
|
||||
connected,
|
||||
pad,
|
||||
&mut tints,
|
||||
&mut out,
|
||||
@@ -2102,8 +2117,9 @@ impl Mesher<'_> {
|
||||
})
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn face(
|
||||
fn face<C: ConnectedTextures>(
|
||||
&self,
|
||||
connected: &C,
|
||||
pad: &Padded,
|
||||
tints: &mut Tints,
|
||||
out: &mut SectionMesh,
|
||||
@@ -2163,55 +2179,40 @@ impl Mesher<'_> {
|
||||
}
|
||||
None => tint,
|
||||
};
|
||||
// Fusion's and then OptiFine's connected textures: another sprite, or one per quarter,
|
||||
// and the overlays drawn over it (a few per face at most; more are dropped).
|
||||
let (fusion, connected) = (&self.render.fusion, &self.render.connected);
|
||||
// Connected textures: another sprite, or one per quarter, and the overlays drawn over it
|
||||
// (a few per face at most; more are dropped).
|
||||
let mut overlays = [None; 8];
|
||||
let mut count = 0;
|
||||
let mut push = |overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
let neighbour = |n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize];
|
||||
let fused = face.fusion.and_then(|rule| {
|
||||
fusion.pick(
|
||||
rule,
|
||||
id,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
q.tint_index,
|
||||
neighbour,
|
||||
let pick = if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
let mut push = |overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
let tints = &*tints;
|
||||
connected.pick(
|
||||
&crate::connected::Face {
|
||||
state: id,
|
||||
layer: face.layer as u16,
|
||||
facing: q.facing as u8,
|
||||
pos: [world.x, world.y, world.z],
|
||||
positions: &q.positions,
|
||||
uv: &q.uv,
|
||||
pane: state.pane,
|
||||
tint_index: q.tint_index,
|
||||
rule: face.fusion,
|
||||
},
|
||||
|n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize],
|
||||
|s| {
|
||||
self.tables
|
||||
.info
|
||||
.get(s as usize)
|
||||
.is_some_and(|i| i.opaque_cube)
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
});
|
||||
let pick = if let Some(pick) = fused {
|
||||
pick
|
||||
} else if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
let tints = &*tints;
|
||||
connected.pick(
|
||||
id,
|
||||
face.layer as u16,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
[world.x, world.y, world.z],
|
||||
neighbour,
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|
||||
|| {
|
||||
if state.pane {
|
||||
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
|
||||
} else {
|
||||
CtmAxes::texture(&q.positions, &q.uv)
|
||||
}
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -100,8 +100,7 @@ fn fixture() -> (Scratch, Assets, RenderAssets, MeshTables, Biomes) {
|
||||
pbr_normals: vec![],
|
||||
pbr_specular: vec![],
|
||||
pbr_size: 1,
|
||||
connected: Default::default(),
|
||||
fusion: Default::default(),
|
||||
connected: None,
|
||||
generation: 1,
|
||||
};
|
||||
let assets = Assets {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
|
||||
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
|
||||
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
|
||||
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
|
||||
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
|
||||
use crate::sprites::Sprites;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
@@ -35,14 +36,12 @@ fn tile_file(name: &str) -> Option<&str> {
|
||||
name.strip_suffix(".png")
|
||||
}
|
||||
|
||||
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each
|
||||
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
|
||||
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
|
||||
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
|
||||
/// the table). A pack without such files adds nothing.
|
||||
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
let suffix = horizon_optifine::emissive::suffix(root);
|
||||
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
|
||||
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
|
||||
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
|
||||
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
|
||||
/// (native mods keep their share of the table). A pack without such files adds nothing.
|
||||
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
|
||||
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
|
||||
// base would glow on every block that shares it, so it gets no overlay.
|
||||
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
|
||||
@@ -56,8 +55,8 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
sharing[layer] == 1
|
||||
&& (name
|
||||
.split_once(':')
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
|
||||
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|
||||
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
|
||||
})
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
@@ -110,7 +109,7 @@ pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)
|
||||
pub const CUTOUT: u32 = 1 << 31;
|
||||
|
||||
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
|
||||
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
|
||||
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
|
||||
pub fn table<'a>(
|
||||
layers: &BTreeMap<String, u32>,
|
||||
@@ -204,7 +203,7 @@ mod tests {
|
||||
// Items draw from the HUD atlas, not the terrain: no pair.
|
||||
sprites.insert(&root, "minecraft:item/stick").unwrap();
|
||||
// glowstone isn't loaded: its overlay pairs with nothing.
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_e", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
[(
|
||||
@@ -215,9 +214,6 @@ mod tests {
|
||||
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
|
||||
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
|
||||
// The pack's own suffix.
|
||||
let props = root.join("assets/minecraft/optifine/emissive.properties");
|
||||
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
|
||||
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
|
||||
// Connected-texture tiles pair with the overlay beside them.
|
||||
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
|
||||
png(&tiles.join("0.png"), 230);
|
||||
@@ -234,7 +230,7 @@ mod tests {
|
||||
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
|
||||
.unwrap();
|
||||
}
|
||||
let pairs = load_optifine(&root, &mut sprites);
|
||||
let pairs = load_overlays(&root, "_glow", &mut sprites);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
// Blocks first, then tiles.
|
||||
|
||||
@@ -1501,7 +1501,7 @@ pub enum Event {
|
||||
/// ones that differ from their defaults (the others went back to theirs).
|
||||
PackOptions {
|
||||
entries: Vec<String>,
|
||||
chosen: std::collections::BTreeMap<String, horizon_polytone::pack::ConfigValue>,
|
||||
chosen: std::collections::BTreeMap<String, horizon_pack_options::ConfigValue>,
|
||||
},
|
||||
/// An accepted button press or the release of a slider.
|
||||
ButtonClick,
|
||||
@@ -1688,7 +1688,7 @@ pub struct OptionsUi {
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
/// fills them beside `pack_infos`).
|
||||
pub pack_options: crate::pack_options_ui::PackOptionsUi,
|
||||
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
|
||||
pub pack_configs: std::collections::BTreeMap<String, horizon_pack_options::Configs>,
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
@@ -4018,7 +4018,7 @@ impl OptionsUi {
|
||||
events.push(Event::DownloadPack(i));
|
||||
}
|
||||
crate::pack_screen::Action::Options(id) => {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
let mut configs = horizon_pack_options::Configs::default();
|
||||
if let Some(own) = self.pack_configs.get(&id) {
|
||||
configs.merge(own);
|
||||
}
|
||||
|
||||
@@ -162,10 +162,38 @@ impl OptionsUi {
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
registered.rows(&mut rows);
|
||||
rows.into_iter()
|
||||
.enumerate()
|
||||
.map(|(r, row)| self.added_row(page, r, row))
|
||||
.collect()
|
||||
let mut out = Vec::new();
|
||||
// The row a half-width button started, waiting for the next to sit beside it.
|
||||
let mut half_open = false;
|
||||
for (r, row) in rows.into_iter().enumerate() {
|
||||
match row {
|
||||
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
|
||||
added::Row::Text { text, .. } => {
|
||||
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
|
||||
}
|
||||
added::Row::Space(_) => continue,
|
||||
added::Row::Button {
|
||||
label,
|
||||
active,
|
||||
half: true,
|
||||
..
|
||||
} => {
|
||||
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
|
||||
if !active {
|
||||
half = inactive(half);
|
||||
}
|
||||
match out.last_mut() {
|
||||
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
|
||||
_ => out.push(Row::Buttons(half, None)),
|
||||
}
|
||||
half_open = !half_open;
|
||||
continue;
|
||||
}
|
||||
row => out.push(self.added_row(page, r, row)),
|
||||
}
|
||||
half_open = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
|
||||
@@ -177,7 +205,10 @@ impl OptionsUi {
|
||||
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
|
||||
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
|
||||
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
|
||||
added::Row::Button { label } => wide(tr(label)),
|
||||
added::Row::Button { label, active, .. } => {
|
||||
let wide = button(0.0, 0.0, 310.0, tr(label), act);
|
||||
Row::Buttons(if active { wide } else { inactive(wide) }, None)
|
||||
}
|
||||
added::Row::Key { label, key } => {
|
||||
let mut message = keybinds::display_name(&key);
|
||||
if self.selected_key == Some(PAGE_KEY)
|
||||
@@ -461,4 +492,84 @@ mod tests {
|
||||
Some(tr("test.sample.switch.tooltip").plain())
|
||||
);
|
||||
}
|
||||
|
||||
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
|
||||
struct Shaped(Mutex<Vec<usize>>);
|
||||
|
||||
impl added::Page for Shaped {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.shaped"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"test.shaped.title"
|
||||
}
|
||||
fn place(&self) -> added::Place {
|
||||
added::Place::HorizonSettings
|
||||
}
|
||||
fn rows(&self, rows: &mut Vec<added::Row>) {
|
||||
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
|
||||
rows.push(added::Row::text("grey").quiet());
|
||||
rows.push(added::Row::Space(6));
|
||||
rows.push(added::Row::button("test.shaped.copy").half());
|
||||
rows.push(added::Row::button("test.shaped.stop").half().active(false));
|
||||
rows.push(added::Row::button("test.shaped.help").footer());
|
||||
}
|
||||
fn act(&self, row: usize, _: added::Act) {
|
||||
self.0.lock().unwrap().push(row);
|
||||
}
|
||||
}
|
||||
|
||||
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
|
||||
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
|
||||
|
||||
#[test]
|
||||
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
|
||||
let mut s = Settings::default();
|
||||
TEST_PAGES.set(Some(&SHAPED_PAGES));
|
||||
let ui = OptionsUi::default();
|
||||
let rows = ui.added_rows(0);
|
||||
// The two texts as notes (space shows nothing here), the halves on one row, the
|
||||
// footer button below them (the settings shell has no footer of its own).
|
||||
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
|
||||
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
|
||||
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
|
||||
panic!("the halves sit side by side: {:?}", rows.len());
|
||||
};
|
||||
assert_eq!((copy.w, stop.w), (150.0, 150.0));
|
||||
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
|
||||
assert_eq!(rows.len(), 4);
|
||||
let mut ui = ui;
|
||||
ui.show(Page::Added(0));
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
|
||||
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
|
||||
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
|
||||
let stop = widgets
|
||||
.iter()
|
||||
.find(|w| {
|
||||
matches!(
|
||||
&w.kind,
|
||||
WKind::Button {
|
||||
act: Act::PageRow(0, 4),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.expect("Stop is drawn");
|
||||
let atlas = super::super::tests::atlas();
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
stop.x + 1.0,
|
||||
stop.y + 1.0,
|
||||
(480.0, 240.0),
|
||||
[480, 240],
|
||||
false,
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
*SHAPED.0.lock().unwrap(),
|
||||
[3, 5],
|
||||
"the inactive half heard nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-6
@@ -12,6 +12,7 @@
|
||||
//! block models here, so their files are not read; copper golem statues have no CEM name.
|
||||
|
||||
use crate::block_entity_render::ModelPart;
|
||||
use crate::pack_hooks::Reshaped;
|
||||
use horizon_optifine::cem::{
|
||||
files::{atlas_name, jem_files, load_jem},
|
||||
model::{Bone, EntityModel},
|
||||
@@ -140,12 +141,6 @@ const TARGETS: &[Target] = &[
|
||||
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
|
||||
];
|
||||
|
||||
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
|
||||
pub type Reshaped = (
|
||||
HashMap<&'static str, ModelPart>,
|
||||
HashMap<&'static str, String>,
|
||||
);
|
||||
|
||||
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
|
||||
/// a `.jem` there.
|
||||
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
|
||||
@@ -0,0 +1,969 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root, and which of them a stack
|
||||
//! matches ([`crate::cit::Matcher`]). The rules themselves are `horizon_optifine::cit`'s; the
|
||||
//! engine gets them as [`crate::cit::Rule`]s.
|
||||
|
||||
use super::cit_glint::{load_global, stack_glint};
|
||||
use crate::cit::{self as engine, CitRules};
|
||||
use crate::cit_glint::StackGlint;
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{
|
||||
Blend, ComponentTest, Factor, Glint, Global, Hand, Kind, Rule, Source, ValueTest,
|
||||
};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
|
||||
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
let props = read_properties(&root.join(&path))?;
|
||||
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
|
||||
if rule.is_none() {
|
||||
log::warn!("custom item textures: {path} is not a rule Horizon can read");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
rules
|
||||
}
|
||||
|
||||
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
|
||||
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
|
||||
/// be read.
|
||||
pub fn read_properties(path: &Path) -> Option<Properties> {
|
||||
let bytes = fs::read(path).ok()?;
|
||||
let text = String::from_utf8(bytes)
|
||||
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
|
||||
Some(Properties::parse_equals_first(&text))
|
||||
}
|
||||
|
||||
/// The rules at `root` with the pack's `cit.properties`, as the engine draws and matches them.
|
||||
pub fn load(root: &Path) -> CitRules {
|
||||
cit_rules(load_rules(root), load_global(root))
|
||||
}
|
||||
|
||||
/// `rules` (in priority order, the winner first) under `global`, for the engine.
|
||||
pub fn cit_rules(rules: Vec<Rule>, global: Global) -> CitRules {
|
||||
let drawn = rules.iter().map(rule).collect();
|
||||
let use_glint = global.use_glint;
|
||||
CitRules::new(drawn, use_glint, Box::new(Matcher::new(rules, global)))
|
||||
}
|
||||
|
||||
/// A rule's file as the engine draws it: `text` read as the `.properties` file at `path`, every
|
||||
/// file it names taken to exist. For tests and tools.
|
||||
pub fn parse_rule(path: &str, text: &str) -> Option<engine::Rule> {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).map(|r| rule(&r))
|
||||
}
|
||||
|
||||
/// [`parse_rule`] for a rule in the pack at `root`, whose files it looks for there.
|
||||
pub fn parse_rule_in(root: &Path, path: &str, text: &str) -> Option<engine::Rule> {
|
||||
let props = Properties::parse_equals_first(text);
|
||||
Rule::parse(path, &props, |p| root.join(p).is_file()).map(|r| rule(&r))
|
||||
}
|
||||
|
||||
/// Rules from (path, `.properties` text) under the global `cit.properties` text `global`, in
|
||||
/// priority order, every file they name taken to exist. For tests and tools.
|
||||
pub fn parse_rules(files: &[(&str, &str)], global: &str) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.filter_map(|(path, text)| {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |_| true)
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
cit_rules(
|
||||
rules,
|
||||
Global::parse(&Properties::parse_equals_first(global)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A `blend=` value as the engine draws it ([`horizon_optifine::cit::parse_blend`]). For tests
|
||||
/// and tools.
|
||||
pub fn parse_blend(value: &str) -> Option<Option<engine::Blend>> {
|
||||
horizon_optifine::cit::parse_blend(value).map(|b| b.map(blend))
|
||||
}
|
||||
|
||||
fn rule(r: &Rule) -> engine::Rule {
|
||||
engine::Rule {
|
||||
path: r.path.clone(),
|
||||
kind: kind(r.kind),
|
||||
texture: r.texture.clone(),
|
||||
textures: r.textures.clone(),
|
||||
model: r.model.clone(),
|
||||
models: r.models.clone(),
|
||||
glint: glint(&r.glint),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(k: Kind) -> engine::Kind {
|
||||
match k {
|
||||
Kind::Item => engine::Kind::Item,
|
||||
Kind::Armor => engine::Kind::Armor,
|
||||
Kind::Elytra => engine::Kind::Elytra,
|
||||
Kind::Enchantment => engine::Kind::Enchantment,
|
||||
}
|
||||
}
|
||||
|
||||
fn of_kind(k: engine::Kind) -> Kind {
|
||||
match k {
|
||||
engine::Kind::Item => Kind::Item,
|
||||
engine::Kind::Armor => Kind::Armor,
|
||||
engine::Kind::Elytra => Kind::Elytra,
|
||||
engine::Kind::Enchantment => Kind::Enchantment,
|
||||
}
|
||||
}
|
||||
|
||||
fn of_hand(h: engine::Hand) -> Hand {
|
||||
match h {
|
||||
engine::Hand::Any => Hand::Any,
|
||||
engine::Hand::Main => Hand::Main,
|
||||
engine::Hand::Off => Hand::Off,
|
||||
}
|
||||
}
|
||||
|
||||
fn glint(g: &Glint) -> engine::Glint {
|
||||
engine::Glint {
|
||||
layer: g.layer,
|
||||
blend: g.blend.map(blend),
|
||||
speed: g.speed,
|
||||
rotation: g.rotation,
|
||||
duration: g.duration,
|
||||
rgba: g.rgba,
|
||||
use_glint: g.use_glint,
|
||||
blur: g.blur,
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(b: Blend) -> engine::Blend {
|
||||
engine::Blend {
|
||||
src: factor(b.src),
|
||||
dst: factor(b.dst),
|
||||
src_alpha: factor(b.src_alpha),
|
||||
dst_alpha: factor(b.dst_alpha),
|
||||
}
|
||||
}
|
||||
|
||||
fn factor(f: Factor) -> engine::Factor {
|
||||
use engine::Factor as F;
|
||||
match f {
|
||||
Factor::Zero => F::Zero,
|
||||
Factor::One => F::One,
|
||||
Factor::SrcColor => F::SrcColor,
|
||||
Factor::OneMinusSrcColor => F::OneMinusSrcColor,
|
||||
Factor::SrcAlpha => F::SrcAlpha,
|
||||
Factor::OneMinusSrcAlpha => F::OneMinusSrcAlpha,
|
||||
Factor::DstAlpha => F::DstAlpha,
|
||||
Factor::OneMinusDstAlpha => F::OneMinusDstAlpha,
|
||||
Factor::DstColor => F::DstColor,
|
||||
Factor::OneMinusDstColor => F::OneMinusDstColor,
|
||||
Factor::SrcAlphaSaturate => F::SrcAlphaSaturate,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
struct Matcher {
|
||||
rules: Vec<Rule>,
|
||||
/// The pack's `cit.properties`.
|
||||
global: Global,
|
||||
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
|
||||
by_item: [HashMap<String, Vec<u32>>; 4],
|
||||
/// Enchantment rules without `items`, in priority order.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl Matcher {
|
||||
/// `rules` in priority order, the winner first.
|
||||
fn new(rules: Vec<Rule>, global: Global) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
Self {
|
||||
rules,
|
||||
global,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches the stack drawn in `hand`.
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if rule.hand != Hand::Any && rule.hand != hand {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
impl engine::Matcher for Matcher {
|
||||
fn keep_glints(&mut self, keep: &dyn Fn(usize) -> bool) {
|
||||
self.any_item.retain(|&i| keep(i as usize));
|
||||
let named = &mut self.by_item[Kind::Enchantment as usize];
|
||||
for list in named.values_mut() {
|
||||
list.retain(|&i| keep(i as usize));
|
||||
}
|
||||
named.retain(|_, list| !list.is_empty());
|
||||
lock(&self.enchanted).clear();
|
||||
lock(&self.glints).clear();
|
||||
}
|
||||
|
||||
fn select(
|
||||
&self,
|
||||
kind: engine::Kind,
|
||||
stack: &ItemStack,
|
||||
hand: engine::Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let (kind, hand) = (of_kind(kind), of_hand(hand));
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
}
|
||||
|
||||
fn enchantments(&self, stack: &ItemStack, hand: engine::Hand, max_damage: u32) -> Vec<usize> {
|
||||
let hand = of_hand(hand);
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
let found = hit.unwrap_or_else(|| {
|
||||
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
|
||||
all.sort_unstable();
|
||||
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
}
|
||||
|
||||
/// Worked out once per stack and hand.
|
||||
fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
hand: engine::Hand,
|
||||
max_damage: u32,
|
||||
foil: bool,
|
||||
) -> Option<Arc<StackGlint>> {
|
||||
let no_rules =
|
||||
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
|
||||
// Before the cache, which `foil` is not part of.
|
||||
if !foil || (no_rules && self.global.use_glint) {
|
||||
return None;
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(2, Kind::Enchantment, of_hand(hand)),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
if let Some(hit) = lock(&self.glints).get(&key) {
|
||||
return Some(hit.clone());
|
||||
}
|
||||
let matched = self.enchantments(stack, hand, max_damage);
|
||||
let glint = Arc::new(stack_glint(
|
||||
&self.rules,
|
||||
&matched,
|
||||
stack.crafting_enchantments(),
|
||||
&self.global,
|
||||
));
|
||||
remember(&self.glints, key, glint.clone());
|
||||
Some(glint)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
|
||||
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
|
||||
let mut h = DefaultHasher::new();
|
||||
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cit::{Hand, Kind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them, under the global
|
||||
/// `cit.properties` text `global`.
|
||||
fn rules_under(files: &[(&str, &str)], global: &str) -> CitRules {
|
||||
let files: Vec<(String, String)> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
(path, format!("texture=x\n{text}"))
|
||||
})
|
||||
.collect();
|
||||
let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (&**p, &**t)).collect();
|
||||
let cit = parse_rules(&files, global);
|
||||
assert_eq!(cit.rules.len(), files.len(), "every rule parses");
|
||||
cit
|
||||
}
|
||||
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
rules_under(files, "")
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// Glints are never an item's texture.
|
||||
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
|
||||
}
|
||||
|
||||
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
|
||||
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
|
||||
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
|
||||
}
|
||||
|
||||
/// `stack` with its `enchantment_glint_override` set to `on`.
|
||||
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
|
||||
let flag = (
|
||||
"minecraft:enchantment_glint_override".to_owned(),
|
||||
Tag::Byte(on.into()),
|
||||
);
|
||||
stack.data_mut().other.push(flag);
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stack_glint_reads_the_book_and_the_pack_file() {
|
||||
let files = [
|
||||
("sharp", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"keep",
|
||||
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
|
||||
),
|
||||
];
|
||||
let cit = rules(&files);
|
||||
// An enchanted book's glint follows its stored enchantments.
|
||||
let mut book = new("enchanted_book");
|
||||
book.data_mut().stored_enchantments = vec![
|
||||
(Arc::from("minecraft:sharpness"), 3),
|
||||
(Arc::from("minecraft:mending"), 1),
|
||||
];
|
||||
let book = glint(&cit, &book, true).unwrap();
|
||||
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(weights, [0.75, 0.25]);
|
||||
assert!(book.vanilla);
|
||||
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
|
||||
assert_eq!(plain.layers.len(), 1);
|
||||
assert!(!plain.vanilla);
|
||||
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
|
||||
let bare = rules(&[("stick", "items=stick")]);
|
||||
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
|
||||
let bare = rules_under(&[("stick", "items=stick")], "useGlint=false");
|
||||
assert!(!bare.use_glint);
|
||||
assert!(
|
||||
!glint(&bare, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.vanilla
|
||||
);
|
||||
let cit = rules_under(&files, "useGlint=false");
|
||||
assert!(
|
||||
glint(&cit, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_stack_with_a_glint_gets_layers() {
|
||||
let cit = rules(&[("any", "type=enchantment")]);
|
||||
assert!(glint(&cit, &new("stick"), false).is_none());
|
||||
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
|
||||
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
|
||||
assert!(glint(&cit, &off, false).is_none());
|
||||
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
|
||||
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
|
||||
assert_eq!(
|
||||
weights,
|
||||
[1.0],
|
||||
"nothing to share: the rule at full strength"
|
||||
);
|
||||
// The same stack, asked again without its glint, is not answered from the cache.
|
||||
assert!(
|
||||
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_left_out_of_the_glints_matches_nothing() {
|
||||
let mut cit = rules(&[
|
||||
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
|
||||
("shown", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |cit: &CitRules| -> Vec<String> {
|
||||
let glint = glint(cit, &sword, false).unwrap();
|
||||
let stems = glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(cit, Some(l.rule)).unwrap());
|
||||
stems.map(str::to_owned).collect()
|
||||
};
|
||||
// The heavier rule wins the layer, until its texture is missing.
|
||||
assert_eq!(layers(&cit), ["broken"]);
|
||||
let broken = (0..cit.rules.len())
|
||||
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
|
||||
.unwrap();
|
||||
cit.keep_glints(|i| i != broken);
|
||||
assert_eq!(layers(&cit), ["shown"]);
|
||||
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
|
||||
// None left: vanilla's glint stays.
|
||||
cit.keep_glints(|_| false);
|
||||
assert!(glint(&cit, &sword, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_off_hand_layer_draws_only_in_the_off_hand() {
|
||||
let cit = rules(&[
|
||||
("left", "type=enchantment\nlayer=1\nhand=off"),
|
||||
("both", "type=enchantment"),
|
||||
]);
|
||||
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
|
||||
let layers = |hand| -> Vec<&str> {
|
||||
let glint = cit.glint(&sword, hand, 0, true).unwrap();
|
||||
glint
|
||||
.layers
|
||||
.iter()
|
||||
.map(|l| stem(&cit, Some(l.rule)).unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(layers(Hand::Off), ["both", "left"]);
|
||||
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
|
||||
assert_eq!(layers(Hand::Main), ["both"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
//! OptiFine's Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's
|
||||
//! matching rules draw, in which order and how strongly, and whether vanilla's glint still draws
|
||||
//! under them ([`crate::cit_glint::StackGlint`]). The pack's `cit.properties` (`Global`) says how
|
||||
//! layers share an item.
|
||||
//!
|
||||
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
|
||||
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
|
||||
//! cycle's fading follow it, except where noted.
|
||||
|
||||
use super::cit::read_properties;
|
||||
use crate::cit_glint::{GlintLayer, StackGlint};
|
||||
use horizon_optifine::cit::{Global, Method, Rule};
|
||||
use std::path::Path;
|
||||
|
||||
/// The pack's global `cit.properties` under the overlay resource root: the first of
|
||||
/// `Global::PATHS` that exists, else the defaults.
|
||||
pub fn load_global(root: &Path) -> Global {
|
||||
Global::PATHS
|
||||
.iter()
|
||||
.find_map(|path| read_properties(&root.join(path)))
|
||||
.map(|props| Global::parse(&props))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A stack's glint: the layers among `matched` (its matching enchantment rules, in priority
|
||||
/// order, as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
|
||||
/// (`ItemStack::crafting_enchantments`).
|
||||
pub fn stack_glint(
|
||||
rules: &[Rule],
|
||||
matched: &[usize],
|
||||
enchantments: &[(impl AsRef<str>, u32)],
|
||||
global: &Global,
|
||||
) -> StackGlint {
|
||||
// The highest priority rule of each layer.
|
||||
let mut layers: Vec<usize> = Vec::new();
|
||||
for &i in matched {
|
||||
let layer = rules[i].glint.layer;
|
||||
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
|
||||
layers.push(i);
|
||||
}
|
||||
}
|
||||
layers.sort_by_key(|&i| rules[i].glint.layer);
|
||||
let cycle = global.method == Method::Cycle;
|
||||
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
|
||||
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
|
||||
// the bottom layers, though, and caps cycles as well).
|
||||
if let Some(cap) = global.cap.filter(|_| !cycle) {
|
||||
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
|
||||
layers = layers.split_off(layers.len().saturating_sub(cap));
|
||||
}
|
||||
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
|
||||
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
|
||||
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
|
||||
let vanilla =
|
||||
global.use_glint && (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
|
||||
// CIT Resewn: shares are against every enchantment of the stack, not only those the
|
||||
// layers name.
|
||||
let levels = enchantments.iter().map(|(_, level)| *level as f32);
|
||||
let total = match global.method {
|
||||
Method::Average => levels.sum::<f32>(),
|
||||
Method::Layered => levels.fold(0.0, f32::max),
|
||||
Method::None | Method::Cycle => 0.0,
|
||||
};
|
||||
let shared = matches!(global.method, Method::Average | Method::Layered);
|
||||
let layers = layers
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
let rule = &rules[i];
|
||||
let named = &rule.enchantments;
|
||||
let weight = if !shared || named.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
// The highest level among the enchantments the rule names whose level also
|
||||
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
|
||||
// stack has, whatever its level).
|
||||
let in_range = |level: u32| {
|
||||
let level = i32::try_from(level).unwrap_or(i32::MAX);
|
||||
rule.enchantment_levels
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.contains(level))
|
||||
};
|
||||
let level = enchantments
|
||||
.iter()
|
||||
.filter(|(id, level)| {
|
||||
let id: &str = id.as_ref();
|
||||
named.iter().any(|n| n == id) && in_range(*level)
|
||||
})
|
||||
.map(|(_, level)| *level as f32)
|
||||
.fold(0.0, f32::max);
|
||||
if total > 0.0 { level / total } else { 0.0 }
|
||||
};
|
||||
GlintLayer::new(i, weight, rule.glint.duration)
|
||||
})
|
||||
.collect();
|
||||
StackGlint::from_parts(layers, vanilla, cycle.then_some(global.fade))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
|
||||
const DIR: &str = "assets/minecraft/optifine/cit";
|
||||
|
||||
/// An enchantment rule named `name` with `text` after `type` and `texture`.
|
||||
fn rule(name: &str, text: &str) -> Rule {
|
||||
let text = format!("type=enchantment\ntexture=glint\n{text}");
|
||||
let path = format!("{DIR}/{name}.properties");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
}
|
||||
|
||||
fn global(text: &str) -> Global {
|
||||
Global::parse(&Properties::parse_equals_first(text))
|
||||
}
|
||||
|
||||
fn rules_of(layers: &StackGlint) -> Vec<usize> {
|
||||
layers.layers.iter().map(|l| l.rule).collect()
|
||||
}
|
||||
|
||||
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
|
||||
|
||||
#[test]
|
||||
fn one_rule_per_layer_bottom_first() {
|
||||
let rules = [
|
||||
rule("a", "layer=2"),
|
||||
rule("b", "layer=0"),
|
||||
rule("c", "layer=2"),
|
||||
rule("d", "layer=-1"),
|
||||
];
|
||||
// Priority order a, b, c, d: c loses layer 2 to a.
|
||||
let g = stack_glint(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [3, 1, 0]);
|
||||
let g = stack_glint(&rules, &[2, 0], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn average_and_layered_shares() {
|
||||
let rules = [
|
||||
rule("sharp", "enchantments=sharpness smite"),
|
||||
rule("unb", "layer=1\nenchantments=unbreaking"),
|
||||
rule("any", "layer=2"),
|
||||
rule("fire", "layer=3\nenchantments=fire_aspect"),
|
||||
];
|
||||
let weights = |method: &str| {
|
||||
let g = stack_glint(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
|
||||
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
|
||||
};
|
||||
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
|
||||
// A rule naming no enchantment is at full strength, one the stack lacks at none.
|
||||
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
|
||||
assert_eq!(weights("method=cycle"), [1.0; 4]);
|
||||
assert_eq!(weights("method=none"), [1.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_level_is_one_the_rules_levels_allow() {
|
||||
let rules = [rule(
|
||||
"holy",
|
||||
"enchantments=sharpness smite\nenchantmentLevels=1-2",
|
||||
)];
|
||||
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
|
||||
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
|
||||
let g = stack_glint(&rules, &[0], &stack, &global("method=layered"));
|
||||
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_keeps_the_topmost_layers() {
|
||||
let rules = [
|
||||
rule("a", "layer=0"),
|
||||
rule("b", "layer=1"),
|
||||
rule("c", "layer=2"),
|
||||
];
|
||||
let g = stack_glint(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
|
||||
assert_eq!(rules_of(&g), [1, 2]);
|
||||
let g = stack_glint(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty());
|
||||
let g = stack_glint(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
// Cycles show every layer in turn.
|
||||
let g = stack_glint(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
|
||||
assert_eq!(rules_of(&g), [0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vanilla_glint() {
|
||||
let plain = rule("plain", "");
|
||||
let keep = rule("keep", "layer=1\nuseGlint=true");
|
||||
let rules = [plain, keep];
|
||||
let vanilla = |matched: &[usize], text: &str| {
|
||||
stack_glint(&rules, matched, &SWORD, &global(text)).vanilla
|
||||
};
|
||||
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
|
||||
assert!(!vanilla(&[0], ""), "a rule replaces it");
|
||||
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
|
||||
assert!(!vanilla(&[], "useGlint=false"));
|
||||
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
|
||||
// Only the layers `cap` keeps count.
|
||||
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
|
||||
let g = stack_glint(&rules, &[0, 1], &SWORD, &global("cap=1"));
|
||||
assert!(!g.vanilla);
|
||||
let g = stack_glint(&rules, &[0, 1], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty() && g.vanilla);
|
||||
let g = stack_glint(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
|
||||
assert!(!g.vanilla);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycles_take_turns_and_fade() {
|
||||
let rules = [
|
||||
rule("a", "duration=2"),
|
||||
rule("b", "layer=1\nduration=1"),
|
||||
rule("c", "layer=2\nduration=1"),
|
||||
];
|
||||
let g = stack_glint(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.5"),
|
||||
);
|
||||
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
|
||||
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
|
||||
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
|
||||
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
|
||||
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
|
||||
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
|
||||
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
|
||||
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
|
||||
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
|
||||
// One layer always shows.
|
||||
let one = stack_glint(&rules, &[1], &SWORD, &global("method=cycle"));
|
||||
assert_eq!(one.intensity(0, 0.7), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cycle_always_shows_a_whole_layer() {
|
||||
let rules = [
|
||||
rule("a", "duration=0.1"),
|
||||
rule("b", "layer=1\nduration=0.7"),
|
||||
rule("c", "layer=2\nduration=0.2"),
|
||||
];
|
||||
let g = stack_glint(
|
||||
&rules,
|
||||
&[0, 1, 2],
|
||||
&SWORD,
|
||||
&global("method=cycle\nfade=0.3"),
|
||||
);
|
||||
// Every millisecond of the first two seconds, and just under a period, the last fade's
|
||||
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
|
||||
// defensive.)
|
||||
let period: f64 = [0.1f32, 0.7, 0.2]
|
||||
.map(|d| f64::from(d) + f64::from(0.3f32))
|
||||
.iter()
|
||||
.sum();
|
||||
let last = f64::from_bits(period.to_bits() - 1);
|
||||
for t in (0..2000_i32)
|
||||
.map(|i| f64::from(i) * 0.001)
|
||||
.chain([last, period + last])
|
||||
{
|
||||
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
|
||||
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
|
||||
}
|
||||
// No time at all: the bottom layer alone, never NaN.
|
||||
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
|
||||
let g = stack_glint(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
|
||||
for t in [0.0, 0.5, 7.25] {
|
||||
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_global_file_wins() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!(load_global(&root), Global::default());
|
||||
let write = |path: &str, text: &[u8]| {
|
||||
let file = root.join(path);
|
||||
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
|
||||
std::fs::write(file, text).unwrap();
|
||||
};
|
||||
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
|
||||
write(
|
||||
"assets/minecraft/optifine/cit/cit.properties",
|
||||
b"# caf\xE9\nmethod=cycle",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
|
||||
assert_eq!(load_global(&root).method, Method::Cycle);
|
||||
write(
|
||||
"assets/minecraft/optifine/cit.properties",
|
||||
b"method=layered",
|
||||
);
|
||||
assert_eq!(load_global(&root).method, Method::Layered);
|
||||
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
|
||||
let g = load_global(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,14 @@ use horizon_polytone::colors::PackColors;
|
||||
use crate::block_entity_render::DYES;
|
||||
use crate::pack_palette::{self, Environment};
|
||||
|
||||
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
|
||||
pack_palette::FluidFog {
|
||||
color: fog.color,
|
||||
radius: fog.radius,
|
||||
fade: fog.fade,
|
||||
}
|
||||
}
|
||||
|
||||
/// The colours of every namespace's file under the pack overlay at `root`.
|
||||
pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
let colors = PackColors::read(root);
|
||||
@@ -29,6 +37,10 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
|
||||
water_fog_brightening_time: env.water_fog_brightening_time,
|
||||
horizon_height: env.horizon_height,
|
||||
},
|
||||
fluid_fog: pack_palette::FluidFogs {
|
||||
water: colors.fluid_fog.water.map(fluid),
|
||||
lava: colors.fluid_fog.lava.map(fluid),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! OptiFine's and Fusion's connected textures as the mesher draws them
|
||||
//! ([`crate::connected::ConnectedTextures`]): a Fusion face first, then OptiFine's rules.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use horizon_optifine::ctm::connect::{Axes, Face};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
|
||||
use super::ctm::ConnectedTextures as Ctm;
|
||||
use super::fusion::FusionTextures;
|
||||
use crate::chunk::Chunk;
|
||||
use crate::connected::{self as engine, Connected, ConnectedTextures, Overlay, Pick};
|
||||
use crate::mesher::{MeshScratch, Mesher, SectionMesh};
|
||||
use crate::models::StateModels;
|
||||
use crate::sprites::Sprites;
|
||||
|
||||
/// A pack's Fusion and OptiFine connected textures.
|
||||
pub struct Rules {
|
||||
ctm: Ctm,
|
||||
fusion: FusionTextures,
|
||||
}
|
||||
|
||||
/// The connected textures under `root`: Fusion's models' tiles, native mods' sprites, then
|
||||
/// OptiFine's rules' tiles go into `sprites`, in that order. `None` without either format's files.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
models: &mut StateModels,
|
||||
native_mods: &mut dyn FnMut(&mut Sprites),
|
||||
) -> Option<Connected> {
|
||||
let fusion = {
|
||||
let _step = crate::spans::startup::step("core.fusion");
|
||||
FusionTextures::load(root, registry, sprites, models)
|
||||
};
|
||||
native_mods(sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &*models;
|
||||
let mut found = Vec::new();
|
||||
let ctm = Ctm::load(
|
||||
root,
|
||||
registry,
|
||||
sprites,
|
||||
|state: BlockStateId, face: Face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
);
|
||||
let rules = Rules { ctm, fusion };
|
||||
(!rules.is_empty()).then(|| Connected {
|
||||
rules: Arc::new(rules),
|
||||
mesh,
|
||||
})
|
||||
}
|
||||
|
||||
/// The mesher on [`Rules`].
|
||||
fn mesh(
|
||||
mesher: &mut Mesher<'_>,
|
||||
rules: &(dyn std::any::Any + Send + Sync),
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
let rules = rules
|
||||
.downcast_ref::<Rules>()
|
||||
.expect("the connected textures this format loaded");
|
||||
mesher.mesh_with(rules, region, sy, out, scratch)
|
||||
}
|
||||
|
||||
impl ConnectedTextures for Rules {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.ctm.is_empty() && self.fusion.is_empty()
|
||||
}
|
||||
|
||||
fn stacked_pane_hides(&self, state: u32, other: u32, centre: [f32; 2]) -> bool {
|
||||
!self.ctm.is_empty() && self.ctm.stacked_pane_hides(state, other, centre)
|
||||
}
|
||||
|
||||
fn pick<'b>(
|
||||
&self,
|
||||
face: &engine::Face,
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
covers: impl Fn(u32) -> bool,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Pick {
|
||||
let side = Face::ALL[usize::from(face.facing)];
|
||||
let fused = face.rule.and_then(|rule| {
|
||||
self.fusion.pick(
|
||||
rule,
|
||||
face.state,
|
||||
side,
|
||||
face.tint_index,
|
||||
&neighbour,
|
||||
covers,
|
||||
&mut overlay,
|
||||
)
|
||||
});
|
||||
if let Some(pick) = fused {
|
||||
pick
|
||||
} else if self.ctm.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
self.ctm.pick(
|
||||
face.state,
|
||||
face.layer,
|
||||
side,
|
||||
face.pos,
|
||||
&neighbour,
|
||||
biome,
|
||||
// A pane's tiles are drawn with the cube's UVs, so that is its texture.
|
||||
|| {
|
||||
if face.pane {
|
||||
Some(Axes::cube(side))
|
||||
} else {
|
||||
Axes::texture(face.positions, face.uv)
|
||||
}
|
||||
},
|
||||
&mut overlay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,88 @@
|
||||
//! OptiFine's random entity textures and ETF's blinking and emissive textures
|
||||
//! (`horizon_optifine::{random_textures, blink, emissive}`) as the engine's
|
||||
//! [`crate::entity_textures`] tables.
|
||||
|
||||
use crate::entity_textures::{
|
||||
self as engine, Blink, Blinks, Emissive, EntityTextures, Entry, RandomTextures,
|
||||
};
|
||||
use horizon_optifine::random::{self, Rules};
|
||||
use std::path::Path;
|
||||
|
||||
/// The pack's variants, blinks and emissive overlays under the overlay resource root.
|
||||
pub fn load(root: &Path) -> EntityTextures {
|
||||
let random = horizon_optifine::random_textures::RandomTextures::scan(root);
|
||||
let blinks = horizon_optifine::blink::Blinks::scan(root, &random);
|
||||
let mut emissive = horizon_optifine::emissive::Emissive::scan(root, &random);
|
||||
emissive.pair_beside(root, blinks.files());
|
||||
let files = random
|
||||
.files()
|
||||
.map(|(f, n)| (f.to_owned(), n.to_owned()))
|
||||
.collect();
|
||||
let generation = random.generation();
|
||||
let (entries, rules) = random.into_parts();
|
||||
let entries = entries
|
||||
.into_iter()
|
||||
.map(|(texture, entry)| {
|
||||
let variants = entry
|
||||
.variant_names()
|
||||
.map(|(n, name)| (n, name.to_owned()))
|
||||
.collect();
|
||||
let rules = entry.rules;
|
||||
(texture, Entry { rules, variants })
|
||||
})
|
||||
.collect();
|
||||
let (blinks, blink_files) = blinks.into_parts();
|
||||
let blinks = blinks
|
||||
.into_iter()
|
||||
.map(|(texture, b)| {
|
||||
let blink = Blink {
|
||||
shut: b.shut,
|
||||
half: b.half,
|
||||
length: b.length,
|
||||
frequency: b.frequency,
|
||||
};
|
||||
(texture, blink)
|
||||
})
|
||||
.collect();
|
||||
let (suffix, overlays, overlay_files) = emissive.into_parts();
|
||||
EntityTextures {
|
||||
random: RandomTextures::new(entries, files, Box::new(Variants(rules)), generation),
|
||||
blinks: Blinks::new(blinks, blink_files),
|
||||
emissive: Emissive::new(suffix, overlays, overlay_files),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule files of a scan.
|
||||
struct Variants(Vec<Rules>);
|
||||
|
||||
impl engine::VariantRules for Variants {
|
||||
fn matching(&self, rules: usize, facts: &engine::Facts, seed: i32) -> Option<u32> {
|
||||
let blocks: Vec<random::Block> = facts
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|b| random::Block {
|
||||
id: b.id,
|
||||
states: b.states,
|
||||
})
|
||||
.collect();
|
||||
let facts = random::Facts {
|
||||
biome: facts.biome,
|
||||
y: facts.y,
|
||||
blocks: &blocks,
|
||||
name: facts.name,
|
||||
profession: facts.profession,
|
||||
color: facts.color,
|
||||
baby: facts.baby,
|
||||
health: facts.health,
|
||||
moon_phase: facts.moon_phase,
|
||||
day_time: facts.day_time,
|
||||
weather: facts.weather.map(|w| match w {
|
||||
engine::Weather::Clear => random::Weather::Clear,
|
||||
engine::Weather::Rain => random::Weather::Rain,
|
||||
engine::Weather::Thunder => random::Weather::Thunder,
|
||||
}),
|
||||
size: facts.size,
|
||||
};
|
||||
self.0.get(rules)?.matching(&facts, seed)
|
||||
}
|
||||
}
|
||||
+4
@@ -273,6 +273,10 @@ fn sheet(
|
||||
}
|
||||
|
||||
impl FusionTextures {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
|
||||
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
|
||||
/// shows one gets its rule.
|
||||
@@ -3,9 +3,17 @@
|
||||
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
|
||||
//! moves to a platform crate unchanged, and nothing else in the crate names it.
|
||||
|
||||
pub(crate) mod block_entity_cem;
|
||||
pub mod cit;
|
||||
mod cit_glint;
|
||||
mod colors;
|
||||
mod connected;
|
||||
mod ctm;
|
||||
mod custom_sky;
|
||||
mod entity_textures;
|
||||
mod fusion;
|
||||
mod item_bars;
|
||||
mod pack_options;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -20,11 +28,63 @@ impl PackFormats for Formats {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn connected_textures(
|
||||
&self,
|
||||
root: &Path,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
sprites: &mut crate::sprites::Sprites,
|
||||
models: &mut crate::models::StateModels,
|
||||
native_mods: &mut dyn FnMut(&mut crate::sprites::Sprites),
|
||||
) -> Option<crate::connected::Connected> {
|
||||
connected::load(root, registry, sprites, models, native_mods)
|
||||
}
|
||||
|
||||
fn emissive_suffix(&self, root: &Path) -> Option<String> {
|
||||
Some(horizon_optifine::emissive::suffix(root))
|
||||
}
|
||||
|
||||
fn item_textures(&self, root: &Path) -> Option<crate::cit::CitRules> {
|
||||
Some(cit::load(root))
|
||||
}
|
||||
|
||||
fn entity_textures(&self, root: &Path) -> Option<crate::entity_textures::EntityTextures> {
|
||||
Some(entity_textures::load(root))
|
||||
}
|
||||
|
||||
fn block_entity_models(
|
||||
&self,
|
||||
root: &Path,
|
||||
vanilla: &std::collections::HashMap<&'static str, crate::block_entity_render::ModelPart>,
|
||||
) -> Option<crate::pack_hooks::Reshaped> {
|
||||
block_entity_cem::reshape(root, vanilla)
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
}
|
||||
|
||||
fn pack_options(&self, source: &crate::packs::Source) -> Option<horizon_pack_options::Configs> {
|
||||
Some(pack_options::read(source))
|
||||
}
|
||||
|
||||
fn overlay_active(
|
||||
&self,
|
||||
entry: &serde_json::Value,
|
||||
options: &horizon_pack_options::Configs,
|
||||
formats_apply: bool,
|
||||
) -> bool {
|
||||
horizon_polytone::pack::overlay_active(entry, options, formats_apply)
|
||||
}
|
||||
|
||||
fn sounds_without_unmet(
|
||||
&self,
|
||||
text: &[u8],
|
||||
options: &horizon_pack_options::Configs,
|
||||
) -> Option<Vec<u8>> {
|
||||
horizon_polytone::pack::sounds_without_unmet(text, options)
|
||||
}
|
||||
|
||||
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
item_bars::read(root)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Polytone's pack options (wiki "Polytone Configs") and the `polytone_condition`s that read
|
||||
//! them, on pack overlays and on `sounds.json` events.
|
||||
|
||||
use crate::packs::Source;
|
||||
use crate::zip::Zip;
|
||||
use horizon_pack_options::Configs;
|
||||
use horizon_polytone::pack::add_entry;
|
||||
use std::fs;
|
||||
|
||||
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
|
||||
/// in the pack's base, not its overlays) at their default values.
|
||||
pub fn read(source: &Source) -> Configs {
|
||||
let mut configs = Configs::default();
|
||||
let id_of = |name: &str| -> Option<String> {
|
||||
let rest = name.strip_prefix("assets/")?;
|
||||
let (namespace, rest) = rest.split_once('/')?;
|
||||
let entry = rest
|
||||
.strip_prefix("polytone/config_entries/")?
|
||||
.strip_suffix(".json")?;
|
||||
Some(format!("{namespace}:{entry}"))
|
||||
};
|
||||
match source {
|
||||
Source::Dir(d) => {
|
||||
let Ok(namespaces) = fs::read_dir(d.join("assets")) else {
|
||||
return configs;
|
||||
};
|
||||
for namespace in namespaces.flatten() {
|
||||
let folder = namespace.path().join("polytone/config_entries");
|
||||
for file in fs::read_dir(&folder).into_iter().flatten().flatten() {
|
||||
let name = format!(
|
||||
"assets/{}/polytone/config_entries/{}",
|
||||
namespace.file_name().to_string_lossy(),
|
||||
file.file_name().to_string_lossy()
|
||||
);
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), fs::read(file.path()))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
add_entry(&mut configs, id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Source::Zip(z) => {
|
||||
let Ok(zip) = Zip::open(z) else {
|
||||
return configs;
|
||||
};
|
||||
let names: Vec<String> = zip
|
||||
.names()
|
||||
.filter(|n| id_of(n).is_some())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
for name in names {
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), zip.read(&name))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
add_entry(&mut configs, id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
@@ -15,17 +15,93 @@ pub trait PackFormats: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
|
||||
/// if its format has them.
|
||||
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The overlay's connected textures: their tiles go into `sprites` (before it finishes), and
|
||||
/// baked faces they draw on may get a rule (`crate::models::BakedFace::fusion`).
|
||||
/// `native_mods` loads native mods' own sprites: call it after the tiles that go before them.
|
||||
fn connected_textures(
|
||||
&self,
|
||||
_root: &Path,
|
||||
_registry: &horizon_world::registry::BlockRegistry,
|
||||
_sprites: &mut crate::sprites::Sprites,
|
||||
_models: &mut crate::models::StateModels,
|
||||
_native_mods: &mut dyn FnMut(&mut crate::sprites::Sprites),
|
||||
) -> Option<crate::connected::Connected> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The overlay's custom item texture rules and their matcher.
|
||||
fn item_textures(&self, _root: &Path) -> Option<crate::cit::CitRules> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The overlay's entity texture variants, blinks and emissive overlays.
|
||||
fn entity_textures(&self, _root: &Path) -> Option<crate::entity_textures::EntityTextures> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The block entity models the overlay reshapes over `vanilla`'s, with their textures;
|
||||
/// `None` when it reshapes none.
|
||||
fn block_entity_models(
|
||||
&self,
|
||||
_root: &Path,
|
||||
_vanilla: &std::collections::HashMap<&'static str, crate::block_entity_render::ModelPart>,
|
||||
) -> Option<Reshaped> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The options the pack (its base, not its overlays) declares, at their defaults.
|
||||
fn pack_options(
|
||||
&self,
|
||||
_source: &crate::packs::Source,
|
||||
) -> Option<horizon_pack_options::Configs> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether an `overlays.entries[]` item of `pack.mcmeta` is active, given the pack's options;
|
||||
/// `formats_apply` is vanilla's answer from its `formats`.
|
||||
fn overlay_active(
|
||||
&self,
|
||||
_entry: &serde_json::Value,
|
||||
_options: &horizon_pack_options::Configs,
|
||||
formats_apply: bool,
|
||||
) -> bool {
|
||||
formats_apply
|
||||
}
|
||||
|
||||
/// A pack's `sounds.json` without the events its options turn off; `None` when it keeps
|
||||
/// them all.
|
||||
fn sounds_without_unmet(
|
||||
&self,
|
||||
_text: &[u8],
|
||||
_options: &horizon_pack_options::Configs,
|
||||
) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The durability bar colours the overlay sets by item, if any.
|
||||
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Every block entity model, reshaped by a pack or vanilla's, and the atlas names of the
|
||||
/// pack's textures by model ([`PackFormats::block_entity_models`]).
|
||||
pub type Reshaped = (
|
||||
std::collections::HashMap<&'static str, crate::block_entity_render::ModelPart>,
|
||||
std::collections::HashMap<&'static str, String>,
|
||||
);
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
struct Vanilla;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
|
||||
use horizon_pack_options::{ConfigKind, ConfigValue, Configs};
|
||||
|
||||
/// What a click asks of the options screen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -481,17 +481,36 @@ impl PackOptionsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use horizon_pack_options::ConfigEntry;
|
||||
|
||||
fn slider(default: f64) -> ConfigEntry {
|
||||
let kind = ConfigKind::Number {
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.1,
|
||||
};
|
||||
ConfigEntry::new(kind, ConfigValue::Number(default))
|
||||
}
|
||||
|
||||
fn in_section(entry: ConfigEntry, section: &str, order: i64) -> ConfigEntry {
|
||||
ConfigEntry {
|
||||
section: Some(section.into()),
|
||||
section_order: Some(order),
|
||||
..entry
|
||||
}
|
||||
}
|
||||
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry(
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
|
||||
);
|
||||
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
|
||||
c.add_entry(
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
c.insert("p:fog".into(), in_section(slider(0.5), "sky", 2));
|
||||
let allowed = vec!["drops".into(), "streaks".into()];
|
||||
let rain = ConfigEntry::new(
|
||||
ConfigKind::Text { allowed },
|
||||
ConfigValue::Text("drops".into()),
|
||||
);
|
||||
c.insert("p:rain".into(), in_section(rain, "weather", 1));
|
||||
let birds = ConfigEntry::new(ConfigKind::Bool, ConfigValue::Bool(true));
|
||||
c.insert("p:birds".into(), birds);
|
||||
c
|
||||
}
|
||||
|
||||
@@ -537,9 +556,16 @@ mod tests {
|
||||
#[test]
|
||||
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
|
||||
let mut c = Configs::default();
|
||||
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
|
||||
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
|
||||
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
|
||||
let entry = |fast: f64, pretty: f64| ConfigEntry {
|
||||
presets: [
|
||||
("fast".into(), ConfigValue::Number(fast)),
|
||||
("pretty".into(), ConfigValue::Number(pretty)),
|
||||
]
|
||||
.into(),
|
||||
..slider(0.5)
|
||||
};
|
||||
c.insert("p:fog".into(), entry(0.0, 1.0));
|
||||
c.insert("p:dust".into(), entry(0.0, 0.8));
|
||||
let mut ui = PackOptionsUi::open(c);
|
||||
// The defaults are no preset.
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
|
||||
@@ -59,6 +59,24 @@ pub struct PackColors {
|
||||
pub xp_bar: [Option<u32>; 2],
|
||||
/// The effect fogs' settings: the fog reads them every frame.
|
||||
pub env: Environment,
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
pub fluid_fog: FluidFogs,
|
||||
}
|
||||
|
||||
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
|
||||
/// vanilla's).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FluidFog {
|
||||
pub color: Option<u32>,
|
||||
pub radius: f32,
|
||||
pub fade: f32,
|
||||
}
|
||||
|
||||
/// The fog the packs' fluid modifiers give water and lava.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct FluidFogs {
|
||||
pub water: Option<FluidFog>,
|
||||
pub lava: Option<FluidFog>,
|
||||
}
|
||||
|
||||
impl Default for PackColors {
|
||||
@@ -72,6 +90,7 @@ impl Default for PackColors {
|
||||
entity_shadows: true,
|
||||
xp_bar: [None; 2],
|
||||
env: Environment::default(),
|
||||
fluid_fog: FluidFogs::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct Pack {
|
||||
/// Polytone `polytone_condition` turns on.
|
||||
pub overlays: Vec<String>,
|
||||
/// The values of the pack's Polytone config entries.
|
||||
pub polytone_configs: horizon_polytone::pack::Configs,
|
||||
pub polytone_configs: Configs,
|
||||
/// `filter.block` entries (namespace and path patterns) hiding resources
|
||||
/// of the packs below this one.
|
||||
pub filter: Vec<Filter>,
|
||||
@@ -419,12 +419,13 @@ fn overlay_applies(entry: &serde_json::Value) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn overlays_of(meta: &serde_json::Value, configs: &horizon_polytone::pack::Configs) -> Vec<String> {
|
||||
fn overlays_of(meta: &serde_json::Value, configs: &Configs) -> Vec<String> {
|
||||
let formats = crate::pack_hooks::formats();
|
||||
meta["overlays"]["entries"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|e| horizon_polytone::pack::overlay_active(e, configs, overlay_applies(e)))
|
||||
.filter(|e| formats.overlay_active(e, configs, overlay_applies(e)))
|
||||
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
|
||||
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
|
||||
.filter(|d| {
|
||||
@@ -471,57 +472,12 @@ pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
.next()?
|
||||
.ok()
|
||||
}
|
||||
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
|
||||
/// in the pack's base, not its overlays) at their default values.
|
||||
pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
let id_of = |name: &str| -> Option<String> {
|
||||
let rest = name.strip_prefix("assets/")?;
|
||||
let (namespace, rest) = rest.split_once('/')?;
|
||||
let entry = rest
|
||||
.strip_prefix("polytone/config_entries/")?
|
||||
.strip_suffix(".json")?;
|
||||
Some(format!("{namespace}:{entry}"))
|
||||
};
|
||||
match source {
|
||||
Source::Dir(d) => {
|
||||
let Ok(namespaces) = fs::read_dir(d.join("assets")) else {
|
||||
return configs;
|
||||
};
|
||||
for namespace in namespaces.flatten() {
|
||||
let folder = namespace.path().join("polytone/config_entries");
|
||||
for file in fs::read_dir(&folder).into_iter().flatten().flatten() {
|
||||
let name = format!(
|
||||
"assets/{}/polytone/config_entries/{}",
|
||||
namespace.file_name().to_string_lossy(),
|
||||
file.file_name().to_string_lossy()
|
||||
);
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), fs::read(file.path()))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
configs.add_entry(id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Source::Zip(z) => {
|
||||
let Ok(zip) = Zip::open(z) else {
|
||||
return configs;
|
||||
};
|
||||
let names: Vec<String> = zip
|
||||
.names()
|
||||
.filter(|n| id_of(n).is_some())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
for name in names {
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), zip.read(&name))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
configs.add_entry(id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A pack's own options (Polytone's config entries, in the pack's base, not its overlays) at
|
||||
/// the values the player chose, else their defaults.
|
||||
pub fn polytone_configs(source: &Source) -> Configs {
|
||||
let mut configs = crate::pack_hooks::formats()
|
||||
.pack_options(source)
|
||||
.unwrap_or_default();
|
||||
for (id, value) in polytone_choices() {
|
||||
if configs.entries().contains_key(&id) {
|
||||
configs.set(&id, value);
|
||||
@@ -530,15 +486,15 @@ pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
|
||||
configs
|
||||
}
|
||||
|
||||
/// A pack's Polytone config entries and one's value, for the game's Pack Options.
|
||||
pub use horizon_polytone::pack::{ConfigValue, Configs};
|
||||
/// A pack's options and one's value, for the game's Pack Options.
|
||||
pub use horizon_pack_options::{ConfigValue, Configs};
|
||||
|
||||
/// The player's Polytone config choices (#11486): what they changed from the packs' defaults,
|
||||
/// by entry id, for every pack. `polytone_configs` lays them over each pack's defaults.
|
||||
static POLYTONE_CHOICES: std::sync::RwLock<BTreeMap<String, horizon_polytone::pack::ConfigValue>> =
|
||||
static POLYTONE_CHOICES: std::sync::RwLock<BTreeMap<String, ConfigValue>> =
|
||||
std::sync::RwLock::new(BTreeMap::new());
|
||||
|
||||
pub fn polytone_choices() -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
|
||||
pub fn polytone_choices() -> BTreeMap<String, ConfigValue> {
|
||||
POLYTONE_CHOICES
|
||||
.read()
|
||||
.map(|c| c.clone())
|
||||
@@ -546,7 +502,7 @@ pub fn polytone_choices() -> BTreeMap<String, horizon_polytone::pack::ConfigValu
|
||||
}
|
||||
|
||||
/// Replaces the player's Polytone config choices (packs read them when they load again).
|
||||
pub fn set_polytone_choices(choices: BTreeMap<String, horizon_polytone::pack::ConfigValue>) {
|
||||
pub fn set_polytone_choices(choices: BTreeMap<String, ConfigValue>) {
|
||||
if let Ok(mut c) = POLYTONE_CHOICES.write() {
|
||||
*c = choices;
|
||||
}
|
||||
@@ -558,8 +514,7 @@ pub fn polytone_choices_file() -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
/// Reads the choices from `path` (none when it is missing or broken).
|
||||
pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
|
||||
use horizon_polytone::pack::ConfigValue;
|
||||
pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, ConfigValue> {
|
||||
let Ok(bytes) = fs::read(path) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
@@ -583,9 +538,8 @@ pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, horizon_polytone::
|
||||
/// Writes the choices to `path`.
|
||||
pub fn save_polytone_choices(
|
||||
path: &Path,
|
||||
choices: &BTreeMap<String, horizon_polytone::pack::ConfigValue>,
|
||||
choices: &BTreeMap<String, ConfigValue>,
|
||||
) -> std::io::Result<()> {
|
||||
use horizon_polytone::pack::ConfigValue;
|
||||
let map: serde_json::Map<String, serde_json::Value> = choices
|
||||
.iter()
|
||||
.map(|(id, v)| {
|
||||
@@ -856,7 +810,8 @@ fn build_overlay_uncached(
|
||||
packs
|
||||
.get(*owner)
|
||||
.and_then(|p| {
|
||||
horizon_polytone::pack::sounds_without_unmet(&b, &p.polytone_configs)
|
||||
crate::pack_hooks::formats()
|
||||
.sounds_without_unmet(&b, &p.polytone_configs)
|
||||
})
|
||||
.unwrap_or(b),
|
||||
),
|
||||
@@ -1606,6 +1561,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn a_sound_entry_whose_polytone_condition_fails_is_left_out() {
|
||||
crate::pack_formats::install();
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("horizon-packs-sound-cond-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
@@ -2121,6 +2077,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn polytone_conditions_turn_overlays_on_from_the_packs_configs() {
|
||||
crate::pack_formats::install();
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-packs-polytone-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -796,11 +796,31 @@ impl Host for Ctx<'_, '_> {
|
||||
.registry
|
||||
.configs
|
||||
.get(id)
|
||||
.map(horizon_polytone::pack::ConfigValue::to_value)
|
||||
.map(horizon_polytone::pack::config_value)
|
||||
.ok_or_else(|| format!("no config {id}")),
|
||||
_ => Err(format!("{name} is not available here")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A particle's own fields, as `particle_method` answers them.
|
||||
fn field(&mut self, root: Root, field: horizon_polytone::expr::Field) -> Option<V> {
|
||||
use horizon_polytone::expr::Field as F;
|
||||
let Subject::Particle(p) = &self.subject else {
|
||||
return None;
|
||||
};
|
||||
if root != Root::Object {
|
||||
return None;
|
||||
}
|
||||
Some(V::Num(match field {
|
||||
F::Age => f64::from(p.age),
|
||||
F::Life => f64::from(p.lifetime),
|
||||
F::Custom => *self.custom,
|
||||
F::X => p.position[0],
|
||||
F::Y => p.position[1],
|
||||
F::Z => p.position[2],
|
||||
F::Temperature | F::Downfall => return None,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(program: &Program, ctx: &mut Ctx) -> Option<f64> {
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
//! sections are immutable behind `Arc` and edited through `Arc::make_mut`, so
|
||||
//! a weak reference tells an edited or replaced section from the one the list
|
||||
//! was built from (see `random_tick_sections`), without keeping its blocks.
|
||||
use crate::{chunk::Section, fast_hash::FxHashMap};
|
||||
use crate::{
|
||||
chunk::{Chunk, Section},
|
||||
fast_hash::FxHashMap,
|
||||
};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
/// A section's POIs: the block index in the section (`y << 8 | z << 4 | x`)
|
||||
@@ -18,13 +21,16 @@ struct Cached<T> {
|
||||
section: Weak<Section>,
|
||||
pois: SectionPois<T>,
|
||||
}
|
||||
/// Cached POI lists by section, `(chunk x, section index, chunk z)`.
|
||||
/// Cached POI lists by chunk column `(chunk x, chunk z)`, each by section
|
||||
/// index: a search through a column looks it up once.
|
||||
pub struct PoiSections<T> {
|
||||
sections: Mutex<Lists<T>>,
|
||||
}
|
||||
struct Lists<T> {
|
||||
by_section: FxHashMap<(i32, i32, i32), Cached<T>>,
|
||||
/// Size above which lists of sections no longer resident are dropped.
|
||||
by_column: FxHashMap<(i32, i32), Vec<Option<Cached<T>>>>,
|
||||
/// How many section lists are kept, and the count above which the lists
|
||||
/// of sections no longer resident are dropped.
|
||||
len: usize,
|
||||
prune_at: usize,
|
||||
}
|
||||
const MIN_PRUNE: usize = 4096;
|
||||
@@ -32,27 +38,61 @@ impl<T> Default for PoiSections<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sections: Mutex::new(Lists {
|
||||
by_section: FxHashMap::default(),
|
||||
by_column: FxHashMap::default(),
|
||||
len: 0,
|
||||
prune_at: MIN_PRUNE,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Copy> PoiSections<T> {
|
||||
/// The POIs of `section`, whose block states `kind` classifies. `kind`
|
||||
/// must give the same answer for a state on every call.
|
||||
pub fn get(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
section: &Arc<Section>,
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
) -> SectionPois<T> {
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(cached) = lists.by_section.get(&key)
|
||||
&& cached.section.as_ptr() == Arc::as_ptr(section)
|
||||
{
|
||||
return cached.pois.clone();
|
||||
impl<T: Copy> Lists<T> {
|
||||
/// The column's lists, for sections up to `last`, after dropping the
|
||||
/// lists of sections no longer resident if there are too many.
|
||||
fn column(
|
||||
&mut self,
|
||||
column: (i32, i32),
|
||||
last: usize,
|
||||
) -> (&mut [Option<Cached<T>>], &mut usize) {
|
||||
if self.len >= self.prune_at {
|
||||
self.prune();
|
||||
}
|
||||
let lists = self.by_column.entry(column).or_default();
|
||||
if lists.len() <= last {
|
||||
lists.resize_with(last + 1, || None);
|
||||
}
|
||||
(lists, &mut self.len)
|
||||
}
|
||||
|
||||
/// Drops the lists of sections no longer resident.
|
||||
fn prune(&mut self) {
|
||||
let mut len = 0;
|
||||
self.by_column.retain(|_, lists| {
|
||||
for c in lists.iter_mut() {
|
||||
if c.as_ref().is_some_and(|c| c.section.strong_count() == 0) {
|
||||
*c = None;
|
||||
}
|
||||
}
|
||||
let kept = lists.iter().filter(|c| c.is_some()).count();
|
||||
len += kept;
|
||||
kept > 0
|
||||
});
|
||||
self.len = len;
|
||||
self.prune_at = (len * 2).max(MIN_PRUNE);
|
||||
}
|
||||
}
|
||||
|
||||
/// The POIs of `section` from `slot`, listed again (and kept there) unless
|
||||
/// `slot` was listed from this very section; `len` counts the slots filled.
|
||||
fn list<'a, T: Copy>(
|
||||
slot: &'a mut Option<Cached<T>>,
|
||||
len: &mut usize,
|
||||
section: &Arc<Section>,
|
||||
kind: &mut impl FnMut(u32) -> Option<T>,
|
||||
) -> &'a SectionPois<T> {
|
||||
let fresh = slot
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.section.as_ptr() == Arc::as_ptr(section));
|
||||
if !fresh {
|
||||
let pois: SectionPois<T> = if section.blocks.maybe_has(|v| kind(v).is_some()) {
|
||||
let mut states = [0u32; 4096];
|
||||
section.unpack(&mut states);
|
||||
@@ -64,18 +104,56 @@ impl<T: Copy> PoiSections<T> {
|
||||
} else {
|
||||
Arc::from([])
|
||||
};
|
||||
if lists.by_section.len() >= lists.prune_at {
|
||||
lists.by_section.retain(|_, c| c.section.strong_count() > 0);
|
||||
lists.prune_at = (lists.by_section.len() * 2).max(MIN_PRUNE);
|
||||
*len += usize::from(slot.is_none());
|
||||
*slot = Some(Cached {
|
||||
section: Arc::downgrade(section),
|
||||
pois,
|
||||
});
|
||||
}
|
||||
&slot.as_ref().expect("just listed").pois
|
||||
}
|
||||
|
||||
impl<T: Copy> PoiSections<T> {
|
||||
/// The POIs of `section`, the `key.1`-th section of the column
|
||||
/// `(key.0, key.2)`, whose block states `kind` classifies. `kind` must give
|
||||
/// the same answer for a state on every call.
|
||||
pub fn get(
|
||||
&self,
|
||||
key: (i32, i32, i32),
|
||||
section: &Arc<Section>,
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
) -> SectionPois<T> {
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let s = usize::try_from(key.1).expect("a section index");
|
||||
let (column, len) = lists.column((key.0, key.2), s);
|
||||
list(&mut column[s], len, section, &mut kind).clone()
|
||||
}
|
||||
|
||||
/// The POIs of `chunk` from block Y `y0` to `y1`, its first section at
|
||||
/// `min_y`, as [`Self::get`] gives them: each section that has any, with
|
||||
/// the Y of its lowest blocks, in order, put in `out`. One lookup of the
|
||||
/// column.
|
||||
pub fn column(
|
||||
&self,
|
||||
chunk: &Chunk,
|
||||
min_y: i32,
|
||||
(y0, y1): (i32, i32),
|
||||
mut kind: impl FnMut(u32) -> Option<T>,
|
||||
out: &mut Vec<(i32, SectionPois<T>)>,
|
||||
) {
|
||||
out.clear();
|
||||
let (first, last) = ((y0 - min_y) >> 4, (y1 - min_y) >> 4);
|
||||
if first > last {
|
||||
return;
|
||||
}
|
||||
let mut lists = self.sections.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (slots, len) = lists.column((chunk.x, chunk.z), last as usize);
|
||||
for s in first as usize..=last as usize {
|
||||
let pois = list(&mut slots[s], len, &chunk.sections[s], &mut kind);
|
||||
if !pois.is_empty() {
|
||||
out.push((min_y + s as i32 * 16, pois.clone()));
|
||||
}
|
||||
}
|
||||
lists.by_section.insert(
|
||||
key,
|
||||
Cached {
|
||||
section: Arc::downgrade(section),
|
||||
pois: pois.clone(),
|
||||
},
|
||||
);
|
||||
pois
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,6 +221,48 @@ mod tests {
|
||||
assert!(index.get((0, 0, 0), &chunk.sections[0], kind).is_empty());
|
||||
}
|
||||
|
||||
/// A column gives each section's list as `get` does, the sections
|
||||
/// without any left out, and keeps them until a section changes.
|
||||
#[test]
|
||||
fn a_column_gives_what_each_section_gives() {
|
||||
let index = PoiSections::<u8>::default();
|
||||
let kind = |v: u32| (v == 5).then_some(5);
|
||||
let mut chunk = Arc::new(Chunk::empty(2, -3));
|
||||
for (x, y, z) in [(1, MIN_Y + 2, 3), (4, MIN_Y + 40, 0), (0, MIN_Y + 41, 15)] {
|
||||
Arc::make_mut(&mut chunk).set(x, y, z, 5);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
index.column(&chunk, MIN_Y, (MIN_Y, MIN_Y + 63), kind, &mut out);
|
||||
let bases: Vec<i32> = out.iter().map(|(base, _)| *base).collect();
|
||||
assert_eq!(bases, [MIN_Y, MIN_Y + 32]);
|
||||
for (base, pois) in &out {
|
||||
let s = (base - MIN_Y) / 16;
|
||||
let one = index.get((2, s, -3), &chunk.sections[s as usize], kind);
|
||||
assert!(Arc::ptr_eq(pois, &one));
|
||||
}
|
||||
// Kept: no rescan.
|
||||
index.column(
|
||||
&chunk,
|
||||
MIN_Y,
|
||||
(MIN_Y, MIN_Y + 63),
|
||||
|_| panic!("rescanned"),
|
||||
&mut out,
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
// An edit lists that section again.
|
||||
Arc::make_mut(&mut chunk).set(4, MIN_Y + 40, 0, 1);
|
||||
let mut rescans = 0;
|
||||
let counted = |v: u32| {
|
||||
rescans += 1;
|
||||
kind(v)
|
||||
};
|
||||
index.column(&chunk, MIN_Y, (MIN_Y + 20, MIN_Y + 63), counted, &mut out);
|
||||
assert!(rescans > 0);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].0, MIN_Y + 32);
|
||||
assert_eq!(&*out[0].1, &[(9 << 8 | 15 << 4, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unloaded_sections_are_dropped() {
|
||||
let index = PoiSections::<u8>::default();
|
||||
@@ -153,7 +273,7 @@ mod tests {
|
||||
index.get((1, i, 0), &Arc::new(Section::default()), kind);
|
||||
}
|
||||
let lists = index.sections.lock().unwrap();
|
||||
assert!(lists.by_section.len() <= 2);
|
||||
assert!(lists.by_section.contains_key(&(0, 0, 0)));
|
||||
assert!(lists.len <= 2);
|
||||
assert!(lists.by_column[&(0, 0)][0].is_some());
|
||||
}
|
||||
}
|
||||
@@ -337,6 +337,13 @@ impl Settings {
|
||||
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
|
||||
.map_or(-1, |i| i as i32)
|
||||
}
|
||||
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
|
||||
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
|
||||
/// settings were made from P1's rather than loaded.
|
||||
pub fn load_stores(path: &Path) {
|
||||
let dir = path.parent();
|
||||
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
|
||||
}
|
||||
pub fn load(path: &Path) -> Self {
|
||||
let mut horizon = path
|
||||
.parent()
|
||||
|
||||
@@ -237,17 +237,17 @@ impl<P: Copy, S: Copy> Lines<P, S> {
|
||||
pub(crate) fn backdrop(batch: &mut HudBatch, atlas: &HudAtlas, in_world: bool) {
|
||||
let (w, h) = (batch.width, batch.height);
|
||||
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::VEIL);
|
||||
style::fill(batch, atlas, [0.0, 0.0, w, h], style::veil());
|
||||
}
|
||||
|
||||
/// The mark and the screen's name over the search field.
|
||||
pub(crate) fn mark(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, title: &str) {
|
||||
let (x, y) = (cols.side_x, cols.side_y);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::ACCENT);
|
||||
style::fill(batch, atlas, [x, y + 2.0, 6.0, 6.0], style::accent());
|
||||
let horizon = Text::literal("Horizon");
|
||||
batch.text_styled(atlas, &horizon, x + 10.0, y, style::WHITE, true);
|
||||
let title = Text::literal(title);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::MUTED, true);
|
||||
batch.text_styled(atlas, &title, x, y + 10.0, style::muted(), true);
|
||||
}
|
||||
|
||||
/// A page in the page list: the open page on a plate with the accent's bar beside it, the
|
||||
@@ -273,13 +273,13 @@ pub(crate) fn page_tab(
|
||||
crate::widgets::target_as(rect, crate::widgets::Kind::Tab(open));
|
||||
}
|
||||
if open {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::ACCENT);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
style::fill(batch, atlas, [px, py, 2.0, ph], style::accent());
|
||||
}
|
||||
let color = match (open || hover, has) {
|
||||
(true, _) => style::WHITE,
|
||||
(false, true) => style::SOFT,
|
||||
(false, false) => style::MUTED,
|
||||
(false, true) => style::soft(),
|
||||
(false, false) => style::muted(),
|
||||
};
|
||||
let ty = py + ((ph - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, name, pw - 12.0));
|
||||
@@ -312,12 +312,12 @@ pub(crate) fn search_field(
|
||||
search.draw(batch, atlas, None);
|
||||
if search.value.is_empty() && !focused {
|
||||
let hint = Text::literal(search.message.clone());
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::MUTED, true);
|
||||
batch.text_styled(atlas, &hint, x + 5.0, search.y, style::muted(), true);
|
||||
}
|
||||
if !search.value.is_empty() {
|
||||
let hover = ui::hovered(cursor, clear[0], clear[1], clear[2], clear[3]);
|
||||
crate::widgets::widget_cursor(hover, true);
|
||||
let color = if hover { style::WHITE } else { style::MUTED };
|
||||
let color = if hover { style::WHITE } else { style::muted() };
|
||||
let cy = y + ((h - 8.0) / 2.0).floor();
|
||||
ui::centered_text(
|
||||
batch,
|
||||
@@ -344,7 +344,7 @@ pub(crate) fn plate(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, head
|
||||
batch,
|
||||
atlas,
|
||||
[px + 8.0, py + 24.0, pw - 16.0, 1.0],
|
||||
style::HEAD_RULE,
|
||||
style::head_rule(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,9 +383,9 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
}
|
||||
let Some(setting) = line.setting else {
|
||||
let (name, color, dy) = match line.group {
|
||||
Some(("", _)) => (page_name(line.page), style::MUTED, 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::MUTED, 3.0),
|
||||
None => (page_name(line.page), style::ACCENT, 4.0),
|
||||
Some(("", _)) => (page_name(line.page), style::muted(), 3.0),
|
||||
Some((name, _)) => (lang::get(name), style::muted(), 3.0),
|
||||
None => (page_name(line.page), style::accent(), 4.0),
|
||||
};
|
||||
let name = Text::literal(name);
|
||||
batch.text_styled(atlas, &name, cols.list_x + 2.0, y + dy, color, true);
|
||||
@@ -393,7 +393,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
};
|
||||
if !after_name {
|
||||
let rule = [cols.list_x + 8.0, y, cols.list_w - 16.0, 1.0];
|
||||
style::fill(batch, atlas, rule, style::ROW_RULE);
|
||||
style::fill(batch, atlas, rule, style::row_rule());
|
||||
}
|
||||
row(batch, setting, y);
|
||||
}
|
||||
@@ -404,7 +404,7 @@ pub(crate) fn list<P: Copy, S: Copy>(
|
||||
&none,
|
||||
cols.list_x + 2.0,
|
||||
cols.top + 4.0,
|
||||
style::MUTED,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -499,7 +499,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
let active = live && (hover || focused || held);
|
||||
if active {
|
||||
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
|
||||
style::fill(batch, atlas, lit, style::HOVER);
|
||||
style::fill(batch, atlas, lit, style::hover());
|
||||
}
|
||||
crate::widgets::widget_cursor(hover, live);
|
||||
// The control socket's widget tree, the narrator and a controller's focus.
|
||||
@@ -523,8 +523,8 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
|
||||
if live {
|
||||
crate::widgets::target_as(rect, kind);
|
||||
}
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let soft = if live { style::SOFT } else { style::MUTED };
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let soft = if live { style::soft() } else { style::muted() };
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let lit = live && (hover || held);
|
||||
let left = rect[0] + style::INSET;
|
||||
@@ -611,7 +611,7 @@ fn lit(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, _]: [f32; 4]) {
|
||||
batch,
|
||||
atlas,
|
||||
[x + 2.0, y + 1.0, w - 4.0, style::ROW - 2.0],
|
||||
style::HOVER,
|
||||
style::hover(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -644,7 +644,7 @@ fn read_row(
|
||||
let room = rect[2] - 2.0 * style::INSET - name_w - 8.0;
|
||||
let value = style::fit(atlas, value, room);
|
||||
let vx = rect[0] + rect[2] - style::INSET - atlas.font.width(&value);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::SOFT, true);
|
||||
batch.text_styled(atlas, &Text::literal(value), vx, ty, style::soft(), true);
|
||||
if focused {
|
||||
style::ring(
|
||||
batch,
|
||||
@@ -749,7 +749,7 @@ fn pad_row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32, view:
|
||||
batch.text_styled(atlas, &yellow(close), right.ceil(), ty, style::WHITE, true);
|
||||
}
|
||||
let left = rect[0] + style::INSET;
|
||||
let color = if live { style::WHITE } else { style::MUTED };
|
||||
let color = if live { style::WHITE } else { style::muted() };
|
||||
let shown = Text::literal(style::fit(atlas, name, chip[0] - 6.0 - left));
|
||||
batch.text_styled(atlas, &shown, left, ty, color, true);
|
||||
if focused {
|
||||
@@ -799,7 +799,7 @@ fn side_lines(
|
||||
];
|
||||
crate::widgets::record(line, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, line, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
}
|
||||
}
|
||||
/// A line under the page list: its inset, and its height.
|
||||
@@ -881,7 +881,7 @@ fn heading(batch: &mut HudBatch, atlas: &HudAtlas, text: &str, [x, y, w, _]: [f3
|
||||
let rect = [x + SIDE_INSET, y + HEADING_Y, w - 2.0 * SIDE_INSET, 8.0];
|
||||
crate::widgets::record(text, rect, false);
|
||||
let shown = Text::literal(style::fit(atlas, text, rect[2]));
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::MUTED, true);
|
||||
batch.text_styled(atlas, &shown, rect[0], rect[1], style::muted(), true);
|
||||
}
|
||||
|
||||
/// Vanilla's scroller, inside the panel's right edge, sized as `ui::Scroll` drags it.
|
||||
@@ -1896,8 +1896,8 @@ impl<S: Screen> Shell<S> {
|
||||
// The row's page is open: lit as the list's open page is.
|
||||
if list.lit == Some(*shown) {
|
||||
let row @ [x, y, _, h] = strip.area;
|
||||
style::fill(batch, atlas, row, style::OPEN);
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::ACCENT);
|
||||
style::fill(batch, atlas, row, style::open());
|
||||
style::fill(batch, atlas, [x, y, 2.0, h], style::accent());
|
||||
}
|
||||
tabs::draw(batch, atlas, strip);
|
||||
}
|
||||
@@ -2134,7 +2134,14 @@ impl<S: Screen> Shell<S> {
|
||||
crate::widgets::narratable(|| crate::widgets::Narratable::text(row.name.clone(), rect));
|
||||
let ty = y + ((style::ROW - 8.0) / 2.0).floor();
|
||||
let name = Text::literal(style::fit(atlas, &row.name, rect[2] - 2.0 * style::INSET));
|
||||
batch.text_styled(atlas, &name, rect[0] + style::INSET, ty, style::MUTED, true);
|
||||
batch.text_styled(
|
||||
atlas,
|
||||
&name,
|
||||
rect[0] + style::INSET,
|
||||
ty,
|
||||
style::muted(),
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let over = |[x, y, w, h]: [f32; 4]| pointer.is_some_and(|at| ui::hovered(at, x, y, w, h));
|
||||
@@ -2240,7 +2247,7 @@ impl<S: Screen> Shell<S> {
|
||||
// Labelled for the widget tree: what the strip says is what a test or an agent reads.
|
||||
crate::widgets::record(about, cols.dock, false);
|
||||
let color = if why.is_some() {
|
||||
style::MUTED
|
||||
style::muted()
|
||||
} else {
|
||||
style::GREY
|
||||
};
|
||||
|
||||
@@ -913,6 +913,20 @@ pub trait Backend {
|
||||
fn unpause(&mut self, channel: ChannelId);
|
||||
/// `Channel.stopped`.
|
||||
fn stopped(&mut self, channel: ChannelId) -> bool;
|
||||
/// Update volume, pitch, and position in a single backend call.
|
||||
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
|
||||
self.set_volume(channel, volume);
|
||||
self.set_pitch(channel, pitch);
|
||||
self.set_position(channel, position);
|
||||
}
|
||||
/// Filter which handles have stopped playing in a single pass.
|
||||
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
|
||||
for &(h, c) in live {
|
||||
if self.stopped(c) {
|
||||
stopped.push(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
|
||||
/// event is one the mod exempted. A backend without a muffle ignores it.
|
||||
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
|
||||
@@ -1494,13 +1508,14 @@ impl SoundEngine {
|
||||
.iter()
|
||||
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
|
||||
);
|
||||
for (h, c) in live.drain(..) {
|
||||
if backend.stopped(c) {
|
||||
self.release_handle(h, backend);
|
||||
}
|
||||
let mut stopped = std::mem::take(&mut self.mapped_scratch);
|
||||
stopped.clear();
|
||||
backend.filter_stopped(&live, &mut stopped);
|
||||
for h in stopped.drain(..) {
|
||||
self.release_handle(h, backend);
|
||||
}
|
||||
// Released handles nobody maps any more.
|
||||
let mut mapped = std::mem::take(&mut self.mapped_scratch);
|
||||
let mut mapped = stopped;
|
||||
mapped.clear();
|
||||
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
|
||||
self.handles
|
||||
@@ -1550,9 +1565,7 @@ impl SoundEngine {
|
||||
h.pitch_edited = pitch_edited;
|
||||
}
|
||||
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
|
||||
backend.set_volume(c, volume);
|
||||
backend.set_pitch(c, pitch);
|
||||
backend.set_position(c, position);
|
||||
backend.update_channel(c, volume, pitch, position);
|
||||
}
|
||||
let instance = self.instances.get_mut(&id).expect("present");
|
||||
if instance.looping
|
||||
|
||||
@@ -304,19 +304,19 @@ impl StudioUi {
|
||||
|
||||
// Card background & highlight
|
||||
if is_selected {
|
||||
style::fill(batch, atlas, rect, style::OPEN);
|
||||
style::fill(batch, atlas, rect, style::open());
|
||||
} else if is_hovered {
|
||||
style::fill(batch, atlas, rect, style::HOVER);
|
||||
style::fill(batch, atlas, rect, style::hover());
|
||||
} else {
|
||||
style::well(batch, atlas, rect);
|
||||
}
|
||||
|
||||
let border_color = if is_selected {
|
||||
style::ACCENT
|
||||
style::accent()
|
||||
} else if is_hovered {
|
||||
style::WHITE
|
||||
} else {
|
||||
style::ROW_RULE
|
||||
style::row_rule()
|
||||
};
|
||||
style::ring(batch, atlas, rect, border_color);
|
||||
|
||||
|
||||
@@ -1251,6 +1251,9 @@ pub struct EditBox {
|
||||
/// `getMessage`: what the narrator calls the box ("<message> edit box:
|
||||
/// <value>").
|
||||
pub message: String,
|
||||
/// `EditBox.setInvertHighlightedTextColor`: a selection draws its text
|
||||
/// inverted (the default) or in the box's own colour (`AnvilScreen`).
|
||||
pub invert_highlighted_text_color: bool,
|
||||
/// Where a borderless box drawn in a frame of its own focuses: a controller's outline goes
|
||||
/// round the frame, not over the text.
|
||||
pub frame: Option<[f32; 4]>,
|
||||
@@ -1543,6 +1546,7 @@ impl EditBox {
|
||||
text_color_uneditable: rgba(112, 112, 112, 255),
|
||||
editable: true,
|
||||
message: String::new(),
|
||||
invert_highlighted_text_color: true,
|
||||
frame: None,
|
||||
}
|
||||
}
|
||||
@@ -1918,7 +1922,11 @@ impl EditBox {
|
||||
let right = self.x + self.width;
|
||||
let (x0, x1) = (cursor_x.min(right), (hx - 1.0).min(right));
|
||||
let (a, b) = (x0.min(x1), x0.max(x1));
|
||||
batch.text_highlight(atlas, (a, ty - 1.0, b - a, 11.0), true);
|
||||
batch.text_highlight(
|
||||
atlas,
|
||||
(a, ty - 1.0, b - a, 11.0),
|
||||
self.invert_highlighted_text_color,
|
||||
);
|
||||
}
|
||||
if show_cursor {
|
||||
if insert {
|
||||
@@ -2628,6 +2636,20 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
|
||||
/// unless the box asks it not to (the anvil's rename box).
|
||||
#[test]
|
||||
fn a_selection_inverts_its_text_unless_the_box_says_not_to() {
|
||||
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
|
||||
assert!(edit.invert_highlighted_text_color);
|
||||
edit.invert_highlighted_text_color = false;
|
||||
edit.set_value("name");
|
||||
edit.set_cursor(0, false);
|
||||
edit.set_cursor(4, true);
|
||||
assert_eq!(edit.selected_text(), "name");
|
||||
assert!(!edit.invert_highlighted_text_color);
|
||||
}
|
||||
|
||||
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
|
||||
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
|
||||
/// copies without taking the text out. A value and the caret still show,
|
||||
|
||||
@@ -119,13 +119,11 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
|
||||
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
|
||||
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
|
||||
# cosmetics and emotes, the "Horizon player" presence, the official server added to
|
||||
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# the server list, the Horizon Settings and Mods pages and Play with Friends, and ray tracing on
|
||||
# request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
|
||||
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
|
||||
# client is best effort: CI builds only the default.
|
||||
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
|
||||
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
|
||||
# on Discord (game/discord.rs).
|
||||
discord = ["horizon-client-lib/discord"]
|
||||
horizon-extras = ["horizon-client-render/horizon-extras", "friends"]
|
||||
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
|
||||
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
|
||||
friends = ["dep:horizon-friends"]
|
||||
|
||||
@@ -287,6 +287,7 @@
|
||||
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
|
||||
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
|
||||
"Turn HDR on to use it.": "Turn HDR on to use it.",
|
||||
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
|
||||
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
|
||||
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
|
||||
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
|
||||
|
||||
@@ -50,6 +50,8 @@ unsafe impl GlobalAlloc for CountingAllocator {
|
||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
// The pack formats the game installs at startup, so their loading is measured too.
|
||||
horizon_client::pack_formats::install();
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("usage: model_library_bench ROOT [--parity]")?;
|
||||
|
||||
@@ -14,19 +14,20 @@ pub use horizon_client_render::{
|
||||
block_entities, block_entity_anim, block_entity_render, block_sprites, block_states, bluemap,
|
||||
bluemap_detect, border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit,
|
||||
cit_armor, cit_assets, cit_glint, client_resources, clouds, collisions, command_block,
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
|
||||
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
|
||||
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
|
||||
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects,
|
||||
entity_textures, environment, extras, fast_hash, fluid_render, fog, font, gfx, graphics,
|
||||
graphics_style, graphics_ui, hdr_calibration, hud, hud_kit, hud_layout, hud_overlays,
|
||||
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
|
||||
menu_blur, menu_style, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
|
||||
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
|
||||
pack_browser, pack_formats, pack_hooks, pack_palette, pack_screen, packs, pad_cursor,
|
||||
pad_glyph, par, particles, poi_sections, polytone_creative_tabs, polytone_post, post_chain,
|
||||
potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw, screen_effects,
|
||||
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
|
||||
social, sound_engine, sound_events, sound_preview, spans, split_layout, split_map,
|
||||
split_profiles, sprites, streaming, structure_block, tab_list, terrain_model, test_block,
|
||||
test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets, world,
|
||||
worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -58,8 +59,6 @@ pub mod debug_overlay;
|
||||
pub mod debugworld;
|
||||
pub mod dialog;
|
||||
pub mod dialog_screen;
|
||||
#[cfg(feature = "discord")]
|
||||
pub mod discord_presence;
|
||||
pub mod end_crystal;
|
||||
pub mod end_gateway;
|
||||
pub mod entities;
|
||||
@@ -91,6 +90,7 @@ pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
pub mod merchant;
|
||||
pub mod mob_formats;
|
||||
pub mod mobs;
|
||||
pub mod mod_content;
|
||||
pub mod mod_registry;
|
||||
@@ -102,8 +102,6 @@ pub mod native_data;
|
||||
pub mod net;
|
||||
pub mod nether_portal;
|
||||
pub mod no_resources;
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
pub mod official_servers;
|
||||
pub mod offline_name;
|
||||
pub mod old_world;
|
||||
pub mod osk;
|
||||
@@ -171,6 +169,7 @@ pub use horizon_client_input::gamepad;
|
||||
pub use horizon_client_input::gameplay_guide;
|
||||
pub use horizon_client_input::held;
|
||||
pub use horizon_client_input::input;
|
||||
pub use horizon_client_input::input_source;
|
||||
pub use horizon_client_input::interaction_input;
|
||||
pub use horizon_client_input::key_names;
|
||||
pub use horizon_client_input::keybinds;
|
||||
|
||||
@@ -13,8 +13,10 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
log::warn!("Trait mods already installed; ignoring the second list");
|
||||
}
|
||||
horizon_threads::configure_allocator();
|
||||
crate::horizon_look::give();
|
||||
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
|
||||
horizon_client::pack_formats::install();
|
||||
horizon_client::mob_formats::install();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
if !cfg!(feature = "horizon-extras") {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
@@ -23,8 +25,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`);
|
||||
// none registers after this point.
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
horizon_hooks::engine::seal();
|
||||
horizon_client::spans::startup::init(std::time::Instant::now());
|
||||
horizon_client::spans::trace::start_from_env();
|
||||
|
||||
@@ -49,6 +49,7 @@ fn pack_files(world_tables: &Option<WorldTables>) -> horizon_world::loot::PackOv
|
||||
}
|
||||
}
|
||||
|
||||
mod activity;
|
||||
mod advancements;
|
||||
mod advancements_screen;
|
||||
mod agent;
|
||||
@@ -128,8 +129,6 @@ mod decorated_pot;
|
||||
mod dedicated;
|
||||
mod deflect;
|
||||
mod dialogs;
|
||||
#[cfg(feature = "discord")]
|
||||
mod discord;
|
||||
mod dispense_fixture;
|
||||
mod dispenser;
|
||||
mod dragon;
|
||||
@@ -373,6 +372,7 @@ mod place_feature_tests;
|
||||
mod play_input;
|
||||
mod play_keys;
|
||||
mod player_equip;
|
||||
pub(crate) mod player_hooks;
|
||||
mod player_spawn;
|
||||
mod pointer_capture;
|
||||
#[cfg(test)]
|
||||
@@ -402,6 +402,7 @@ mod remote_vehicles;
|
||||
mod render_control;
|
||||
mod render_settings;
|
||||
mod render_tick;
|
||||
mod replay;
|
||||
mod repro;
|
||||
mod resource_load;
|
||||
mod resource_loader;
|
||||
@@ -1497,7 +1498,7 @@ impl LevelAccess for GameLevel<'_> {
|
||||
};
|
||||
}
|
||||
if let Some(signal) = books::lectern_signal(self.world, pos) {
|
||||
return signal;
|
||||
return signal * i32::from(books::has_book_state(self.registry, state));
|
||||
}
|
||||
if let Some(signal) =
|
||||
machines::shelf_signal(self.world, self.registry, state, pos, direction)
|
||||
@@ -2882,19 +2883,9 @@ struct Game<'a> {
|
||||
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
|
||||
/// service's presence status.
|
||||
realm_session: bool,
|
||||
/// Active packet replay session (game/studio.rs).
|
||||
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
|
||||
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
|
||||
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
|
||||
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
|
||||
pub(super) studio_rmb_down: bool,
|
||||
studio_camera_track_dirty: bool,
|
||||
/// Camera perspective to restore after leaving replay playback.
|
||||
studio_replay_previous_camera_type: Option<perspective::CameraType>,
|
||||
/// Replay opened from the title screen owns the synthetic remote session.
|
||||
studio_replay_standalone: bool,
|
||||
last_replay_tick: Option<Instant>,
|
||||
pub(super) studio_virtual_tick_ms: f32,
|
||||
/// Replay playback (game/replay.rs), and Horizon Studio's editor on it (game/studio.rs).
|
||||
pub(super) replay: replay::Replay,
|
||||
pub(super) studio: studio::Studio,
|
||||
/// This world is open to the network (game/lan.rs).
|
||||
lan: Option<lan::LanHost>,
|
||||
/// Single player's server thread, which `remote` is connected to.
|
||||
@@ -5026,7 +5017,7 @@ impl Game<'_> {
|
||||
if canceled {
|
||||
return Ok(false);
|
||||
}
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
|
||||
self.input_feedback(horizon_client::input_source::Feedback::BlockBroken);
|
||||
let broken = state;
|
||||
let facts = self.res.facts().get(state).clone();
|
||||
let state = self.crack_mined_pot(pos, state);
|
||||
@@ -5845,7 +5836,7 @@ impl Game<'_> {
|
||||
batch
|
||||
}
|
||||
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
|
||||
self.pad.guide_shown.clear();
|
||||
self.input_hud_begin();
|
||||
let mut batch = HudBatch::new(size);
|
||||
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
|
||||
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
|
||||
@@ -5942,8 +5933,7 @@ impl Game<'_> {
|
||||
// the sleep overlay, debug screen and open screens still draw.
|
||||
let spectator = self.spectator();
|
||||
let (wi, hi) = (w as i32, h as i32);
|
||||
let in_studio = self.studio_replay.is_some();
|
||||
let show_hud = !self.ui.hide_gui && !in_studio;
|
||||
let show_hud = !self.ui.hide_gui && !self.replay.playing();
|
||||
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
|
||||
// defers the subtitles over itself, and keeps them while F1 hides the
|
||||
// rest of the HUD.
|
||||
@@ -6282,23 +6272,7 @@ impl Game<'_> {
|
||||
self.draw_sign_editor(&mut batch);
|
||||
self.draw_book(&mut batch);
|
||||
}
|
||||
if !self.ui.hide_gui
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
{
|
||||
self.studio_topbar.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
self.studio_freecam.locked_to_path,
|
||||
);
|
||||
self.studio_timeline.render(
|
||||
&mut batch,
|
||||
&self.gui.atlas,
|
||||
self.cursor,
|
||||
&self.studio_freecam,
|
||||
);
|
||||
}
|
||||
self.draw_studio_bars(&mut batch);
|
||||
if show_hud {
|
||||
self.draw_pad_guide(&mut batch, guide_from);
|
||||
}
|
||||
@@ -6521,12 +6495,12 @@ impl Game<'_> {
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
if self.studio_freecam.active {
|
||||
if self.studio.flying() {
|
||||
return camera_rotation(
|
||||
perspective::CameraType::FirstPerson,
|
||||
self.studio_freecam.yaw,
|
||||
self.studio_freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
|
||||
self.studio.freecam.yaw,
|
||||
self.studio.freecam.pitch,
|
||||
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
|
||||
}
|
||||
match self.posed_rotation {
|
||||
// The frame's camera, already turned round in front view.
|
||||
@@ -6733,11 +6707,7 @@ impl Game<'_> {
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
|
||||
let player_alpha = if self.studio_replay.is_some() {
|
||||
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
self.clock.camera_fraction()
|
||||
};
|
||||
let player_alpha = self.own_player_alpha();
|
||||
self.player_model_geometry(camera, player_alpha, &mut out);
|
||||
// A native pack's shadow pass alone gets the first-person model (no per-entity draws).
|
||||
if self.pack_ids.is_none() && !self.own_body_in_view() {
|
||||
@@ -6746,8 +6716,8 @@ impl Game<'_> {
|
||||
.extend(out.drain(player_start..));
|
||||
}
|
||||
self.mark_motion(out.len(), 0);
|
||||
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
|
||||
self.cosmetics_fx_geometry(camera, alpha, &mut out);
|
||||
// What crates draw around players: first person, see-through (game/player_hooks.rs).
|
||||
self.players_after_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
self.block_item_display_geometry(camera, alpha, &mut out);
|
||||
@@ -7466,7 +7436,6 @@ impl Game<'_> {
|
||||
self.spawn_phantoms();
|
||||
self.tick_advancements();
|
||||
self.tick_player_model();
|
||||
self.tick_emotes();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -9236,7 +9205,7 @@ impl Game<'_> {
|
||||
let camera_alpha = self.clock.camera_fraction();
|
||||
let vanilla = self.camera_view(camera_alpha as f32);
|
||||
let view = vanilla.map(|view| {
|
||||
if self.shows_panorama() || self.studio_freecam.active {
|
||||
if self.shows_panorama() || self.studio.flying() {
|
||||
view
|
||||
} else {
|
||||
self.native_camera_pose_now(camera_alpha as f32, view)
|
||||
@@ -9414,14 +9383,7 @@ impl Game<'_> {
|
||||
// hitboxes (F3+G, F3+B).
|
||||
let mut lines = self.fishing_line(alpha, size);
|
||||
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
|
||||
if self.studio_freecam.active || self.studio_replay.is_some() {
|
||||
horizon_client::studio::render_camera_path(
|
||||
&self.studio_freecam.track,
|
||||
&mut g,
|
||||
None,
|
||||
100,
|
||||
);
|
||||
}
|
||||
self.studio_camera_path(&mut g);
|
||||
lines.extend(g.lines);
|
||||
let on_top = g.on_top;
|
||||
pr.lap("pr_debug");
|
||||
@@ -9443,7 +9405,7 @@ impl Game<'_> {
|
||||
Vec::new()
|
||||
};
|
||||
pr.lap("pr_native_visuals");
|
||||
let view = if self.studio_freecam.active {
|
||||
let view = if self.studio.flying() {
|
||||
glam::Mat4::from_quat(self.camera_orientation().conjugate())
|
||||
} else {
|
||||
horizon_client::camera::view_matrix(view_yaw, view_pitch)
|
||||
@@ -9569,7 +9531,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.fov_degrees(camera_alpha as f32, camera.to_array())
|
||||
};
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
|
||||
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
|
||||
let bob = if !camera_effects {
|
||||
glam::Mat4::IDENTITY
|
||||
} else {
|
||||
@@ -10074,7 +10036,7 @@ impl Game<'_> {
|
||||
)
|
||||
)
|
||||
{
|
||||
self.pad.keyboard_took_over = true;
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
if let WindowEvent::KeyboardInput { event, .. } = &event
|
||||
&& event.state == ElementState::Pressed
|
||||
@@ -10462,21 +10424,7 @@ impl Game<'_> {
|
||||
let b = HudBatch::new([r.config.width, r.config.height]);
|
||||
let previous = self.cursor;
|
||||
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
|
||||
if self.screen == Screen::Playing
|
||||
&& (self.studio_timeline.dragging_playhead
|
||||
|| self.studio_timeline.dragging_keyframe.is_some())
|
||||
{
|
||||
let action = self.studio_timeline.handle_mouse_drag(
|
||||
self.cursor,
|
||||
b.width,
|
||||
&mut self.studio_freecam,
|
||||
);
|
||||
if matches!(
|
||||
action,
|
||||
horizon_client::studio::TimelineAction::KeyframeMoved
|
||||
) {
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
if self.studio_mouse_dragged(b.width) {
|
||||
return Ok(());
|
||||
}
|
||||
if self.screen_mouse_dragged(previous) {
|
||||
@@ -10527,7 +10475,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
WindowEvent::MouseWheel { .. }
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
|
||||
if self.screen == Screen::Playing && self.replay.playing() => {}
|
||||
WindowEvent::MouseWheel { delta, .. } => {
|
||||
let (raw_x, raw_y) = match delta {
|
||||
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
|
||||
@@ -10804,90 +10752,7 @@ impl Game<'_> {
|
||||
}
|
||||
WindowEvent::MouseInput { state, button, .. } => {
|
||||
let pressed = state == ElementState::Pressed;
|
||||
if !pressed && button == MouseButton::Left {
|
||||
let action = self.studio_timeline.handle_mouse_up();
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
}
|
||||
}
|
||||
if (self.studio_freecam.active || self.studio_replay.is_some())
|
||||
&& button == MouseButton::Right
|
||||
{
|
||||
if let Some(renderer) = &self.renderer {
|
||||
let batch =
|
||||
HudBatch::new([renderer.config.width, renderer.config.height]);
|
||||
if self.studio_timeline.visible
|
||||
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
|
||||
{
|
||||
if pressed
|
||||
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
)
|
||||
{
|
||||
if index < self.studio_freecam.track.keyframes.len() {
|
||||
self.studio_freecam.track.keyframes.remove(index);
|
||||
self.studio_camera_track_dirty = true;
|
||||
}
|
||||
self.studio_timeline.selected_keyframe = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed {
|
||||
self.studio_topbar.close_dropdown();
|
||||
self.studio_rmb_down = true;
|
||||
self.capture(true);
|
||||
return Ok(());
|
||||
} else if self.studio_rmb_down {
|
||||
self.studio_rmb_down = false;
|
||||
self.capture(false);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if pressed
|
||||
&& button == MouseButton::Left
|
||||
&& self.screen == Screen::Playing
|
||||
&& (self.studio_replay.is_some() || self.studio_freecam.active)
|
||||
{
|
||||
let size = self
|
||||
.renderer
|
||||
.as_ref()
|
||||
.map(|renderer| [renderer.config.width, renderer.config.height])
|
||||
.unwrap_or([1, 1]);
|
||||
let batch = HudBatch::new(size);
|
||||
let had_open_menu = self.studio_topbar.open_menu.is_some();
|
||||
let topbar_action = self.studio_topbar.handle_mouse_click(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
);
|
||||
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
|
||||
self.handle_studio_topbar_action(topbar_action);
|
||||
return Ok(());
|
||||
}
|
||||
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
|
||||
return Ok(());
|
||||
}
|
||||
if self.studio_timeline.visible {
|
||||
let action = self.studio_timeline.handle_mouse_down(
|
||||
self.cursor,
|
||||
batch.width,
|
||||
batch.height,
|
||||
&self.studio_freecam,
|
||||
);
|
||||
if !matches!(action, horizon_client::studio::TimelineAction::None) {
|
||||
self.handle_studio_timeline_action(action);
|
||||
return Ok(());
|
||||
}
|
||||
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.screen == Screen::Playing && self.studio_replay.is_some() {
|
||||
if self.studio_mouse_input(pressed, button) {
|
||||
return Ok(());
|
||||
}
|
||||
if matches!(self.screen, Screen::Inventory | Screen::Creative)
|
||||
@@ -11357,24 +11222,7 @@ impl Game<'_> {
|
||||
let between = now.saturating_duration_since(self.frame_end);
|
||||
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
|
||||
self.previous = now;
|
||||
if self.studio_freecam.active
|
||||
&& self.screen == Screen::Playing
|
||||
&& !self.dialog_open()
|
||||
{
|
||||
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
|
||||
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
|
||||
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
|
||||
let vertical =
|
||||
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
|
||||
let fast = self.keys.raw(&KeyCode::ControlRight);
|
||||
self.studio_freecam.update_flight_motion(
|
||||
forward,
|
||||
strafe,
|
||||
vertical,
|
||||
fast,
|
||||
elapsed.as_secs_f64().min(0.1),
|
||||
);
|
||||
}
|
||||
self.studio_fly(elapsed);
|
||||
// Arrow-key camera controls belong to gameplay; chat and
|
||||
// other screens use the same keys for their own navigation.
|
||||
if self.screen == Screen::Playing
|
||||
@@ -11404,8 +11252,7 @@ impl Game<'_> {
|
||||
self.heal_hold_latches();
|
||||
// For the hooks above the engine (the "I'm playing" ping): said when it changes.
|
||||
horizon_hooks::engine::published(self.is_published());
|
||||
#[cfg(feature = "discord")]
|
||||
self.update_discord_presence();
|
||||
self.report_activity();
|
||||
let steps = self.clock.advance(elapsed);
|
||||
for _ in 0..steps {
|
||||
let started = Instant::now();
|
||||
@@ -11447,7 +11294,7 @@ impl Game<'_> {
|
||||
self.extras_frame(elapsed);
|
||||
self.extras_retry()?;
|
||||
self.apply_mouse_look();
|
||||
self.pad_look(elapsed.as_secs_f32());
|
||||
self.input_look(elapsed.as_secs_f32());
|
||||
self.advance_flight();
|
||||
self.tick_world_icon();
|
||||
let ticked = Instant::now();
|
||||
@@ -11559,17 +11406,18 @@ impl Game<'_> {
|
||||
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
|
||||
}
|
||||
self.clock.end_frame(self.pause);
|
||||
let mut consumed = false;
|
||||
let saved = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.map(|r| std::mem::take(&mut r.saved_screenshots))
|
||||
.unwrap_or_default();
|
||||
for saved in saved {
|
||||
self.finish_screenshot(saved);
|
||||
}
|
||||
let finished = self
|
||||
.renderer
|
||||
.as_mut()
|
||||
.and_then(|r| r.screenshot_result.take());
|
||||
if let Some(result) = &finished
|
||||
&& self.ui.screenshot_message.is_some()
|
||||
{
|
||||
self.finish_screenshot(result);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) = &finished
|
||||
&& let Some(reply) = self.pending_screenshot.take()
|
||||
{
|
||||
@@ -11582,7 +11430,6 @@ impl Game<'_> {
|
||||
}
|
||||
.to_string(),
|
||||
);
|
||||
consumed = true;
|
||||
}
|
||||
if let Some(result) =
|
||||
self.renderer.as_mut().and_then(|r| r.capture_result.take())
|
||||
@@ -11591,12 +11438,6 @@ impl Game<'_> {
|
||||
let value = result.unwrap_or_else(|e| serde_json::json!({ "error": e }));
|
||||
let _ = reply.send(value.to_string());
|
||||
}
|
||||
// A result nobody asked for was the world icon's
|
||||
// (`tick_world_icon`): a failure tries again a second
|
||||
// later, as `tryTakeScreenshotIfNeeded` does.
|
||||
if !consumed && let Some(Err(error)) = finished {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
if rendered {
|
||||
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
|
||||
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
|
||||
@@ -12157,16 +11998,8 @@ impl<'a> Game<'a> {
|
||||
entity_attachments: Default::default(),
|
||||
remote: None,
|
||||
realm_session: false,
|
||||
studio_replay: None,
|
||||
studio_freecam: Default::default(),
|
||||
studio_timeline: Default::default(),
|
||||
studio_topbar: Default::default(),
|
||||
studio_rmb_down: false,
|
||||
studio_camera_track_dirty: false,
|
||||
studio_replay_previous_camera_type: None,
|
||||
studio_replay_standalone: false,
|
||||
last_replay_tick: None,
|
||||
studio_virtual_tick_ms: 0.0,
|
||||
replay: Default::default(),
|
||||
studio: Default::default(),
|
||||
lan: None,
|
||||
integrated: None,
|
||||
level_shapes: Default::default(),
|
||||
@@ -12665,10 +12498,8 @@ pub fn run_with() -> Result<()> {
|
||||
// horizon-ctl drives: those start in a source checkout and wrote the list there.
|
||||
if !options.offscreen && !synchronous_startup(&options) {
|
||||
let version = horizon_client::telemetry::version();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
|
||||
// horizon-extras: the official server is Horizon's (docs/forking.md).
|
||||
#[cfg(feature = "horizon-extras")]
|
||||
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
|
||||
let game_dir = horizon_client::logging::player_dir();
|
||||
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
|
||||
}
|
||||
mods::register_components(&vanilla_root)?;
|
||||
mods::register_standalone_models();
|
||||
@@ -12696,15 +12527,8 @@ pub fn run_with() -> Result<()> {
|
||||
if options.execution_role.is_client() {
|
||||
narrator::start(&early_settings);
|
||||
}
|
||||
// "Playing Horizon" on Discord: never for a server; an automated run only talks to a test's fake Discord.
|
||||
#[cfg(feature = "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);
|
||||
}
|
||||
// A game window's hooks (Discord's presence): never for a server.
|
||||
activity::client_started(&options, &early_settings);
|
||||
// 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"));
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! What the player is doing, for the hooks above the engine (`Hooks::activity`: the Horizon
|
||||
//! layer's Discord presence shows it).
|
||||
use super::*;
|
||||
use horizon_client::command::args::GameMode;
|
||||
use horizon_hooks::{Activity, Client, Dimension, Play};
|
||||
|
||||
/// At startup, for a game window (never a dedicated server's): the player's first activity, then
|
||||
/// `Hooks::client_started`.
|
||||
pub(super) fn client_started(options: &Options, settings: &Settings) {
|
||||
if options.dedicated.is_some() {
|
||||
return;
|
||||
}
|
||||
let automated = options.benchmark.is_some()
|
||||
|| options.offscreen
|
||||
|| options.frames.is_some()
|
||||
|| options.world_frames.is_some();
|
||||
// The Discord switch's state is the first thing the presence hears, so nothing is sent with
|
||||
// it off.
|
||||
horizon_hooks::engine::activity(match settings.extras.discord_presence {
|
||||
true => Activity::Menus,
|
||||
false => Activity::Hidden,
|
||||
});
|
||||
horizon_hooks::engine::client_started(&Client::new(automated));
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Each frame: only a change reaches the hooks.
|
||||
pub(super) fn report_activity(&self) {
|
||||
// One status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
horizon_hooks::engine::activity(self.activity());
|
||||
}
|
||||
fn activity(&self) -> Activity {
|
||||
if !self.settings.extras.discord_presence {
|
||||
return Activity::Hidden;
|
||||
}
|
||||
if self.title.is_some() {
|
||||
return Activity::Menus;
|
||||
}
|
||||
let max = horizon_client::lan::MAX_PLAYERS;
|
||||
let play = if let Some(host) = &self.lan {
|
||||
Play::Hosting {
|
||||
players: 1 + host.hub.guests.len(),
|
||||
max,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max,
|
||||
},
|
||||
_ => Play::Singleplayer,
|
||||
}
|
||||
} else if self.remote.is_some() {
|
||||
Play::Multiplayer
|
||||
} else {
|
||||
Play::Singleplayer
|
||||
};
|
||||
Activity::World {
|
||||
play,
|
||||
dimension: match self.dim {
|
||||
Dim::Overworld => Dimension::Overworld,
|
||||
Dim::Nether => Dimension::Nether,
|
||||
Dim::End => Dimension::End,
|
||||
},
|
||||
mode: match self.mode {
|
||||
GameMode::Survival => horizon_hooks::GameMode::Survival,
|
||||
GameMode::Creative => horizon_hooks::GameMode::Creative,
|
||||
GameMode::Adventure => horizon_hooks::GameMode::Adventure,
|
||||
GameMode::Spectator => horizon_hooks::GameMode::Spectator,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -848,34 +848,20 @@ impl Game<'_> {
|
||||
self.armor_stands[i].last_hit = time;
|
||||
return true;
|
||||
}
|
||||
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
|
||||
let a = self.armor_stands.remove(i);
|
||||
let (width, height) = stand_box(&a);
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
|
||||
let stand = ItemStack {
|
||||
item: Arc::from("minecraft:armor_stand"),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
};
|
||||
self.spawn_item(stand, center, [0.0; 3], 10);
|
||||
for stack in a.equipment.into_iter().flatten() {
|
||||
let r = &mut self.world.random;
|
||||
let v = [
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
0.2,
|
||||
f64::from(r.next_float()) * 0.2 - 0.1,
|
||||
];
|
||||
self.spawn_item(stack, center, v, 10);
|
||||
// brokenByPlayer: the stand item, with the stand's name, pops at its
|
||||
// block, then brokenByAnything: the loot table and the equipment.
|
||||
let (width, height) = stand_box(&self.armor_stands[i]);
|
||||
let p = self.armor_stands[i].position;
|
||||
let pos = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
|
||||
let mut stand = ItemStack::new("minecraft:armor_stand", 1);
|
||||
if let Some(name) = &self.armor_stands[i].custom_name {
|
||||
stand.data_mut().custom_name =
|
||||
Some(horizon_client::scoreboard::component_tag(&name.component()));
|
||||
}
|
||||
self.pop_resource(pos, stand);
|
||||
// ArmorStand.kill(level, the causing entity), after the drops.
|
||||
self.stand_game_event("minecraft:entity_die", at, by);
|
||||
self.world
|
||||
.modified
|
||||
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
|
||||
self.break_armor_stand_with_source(i, by);
|
||||
self.show_breaking_particles(at, f64::from(width), f64::from(height));
|
||||
true
|
||||
}
|
||||
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
|
||||
@@ -1202,6 +1188,8 @@ impl Game<'_> {
|
||||
let a = self.armor_stands.remove(i);
|
||||
let at = [a.position.x, a.position.y, a.position.z];
|
||||
let a_id = a.id;
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
// brokenByAnything's dropAllDeathLoot, before the equipment.
|
||||
if !a.small && self.ui.rules.bool("mob_drops") {
|
||||
self.armor_stand_death_loot(at, a.fire_ticks > 0);
|
||||
@@ -1210,8 +1198,6 @@ impl Game<'_> {
|
||||
for stack in drops {
|
||||
self.pop_resource(pos, stack);
|
||||
}
|
||||
self.audio
|
||||
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
|
||||
self.entity_killed_event_by(at, source.or(Some(super::commands::Target::Stand(a_id))));
|
||||
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
|
||||
}
|
||||
|
||||
@@ -42,12 +42,25 @@ impl Game<'_> {
|
||||
self.trial_spawner_tick_positions.clear();
|
||||
self.item_columns = Some(Default::default());
|
||||
self.pass_mobs = Some(Default::default());
|
||||
for slot in self.world.block_entity_tickers().iter().copied() {
|
||||
let tickers = self.world.block_entity_tickers();
|
||||
let mut last_chunk = (i32::MIN, i32::MIN);
|
||||
let mut last_chunk_ticking = false;
|
||||
for slot in tickers.iter().copied() {
|
||||
let pos = slot.0;
|
||||
let chunk = (pos.0 >> 4, pos.2 >> 4);
|
||||
let chunk_ticking = if chunk == last_chunk {
|
||||
last_chunk_ticking
|
||||
} else {
|
||||
last_chunk = chunk;
|
||||
last_chunk_ticking = ticking(pos.0, pos.2);
|
||||
last_chunk_ticking
|
||||
};
|
||||
if !chunk_ticking {
|
||||
continue;
|
||||
}
|
||||
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
|
||||
if self.world.block_entities.is_asleep(&pos)
|
||||
|| !self.world.block_entities.is_live(slot)
|
||||
|| !ticking(pos.0, pos.2)
|
||||
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
|
||||
{
|
||||
continue;
|
||||
@@ -210,8 +223,14 @@ impl Game<'_> {
|
||||
/// (`ServerLevel.areEntitiesLoaded`).
|
||||
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
|
||||
let levels = self.tickets.levels();
|
||||
let pending = self.world.entities_pending.clone();
|
||||
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
|
||||
let has_pending = !self.world.entities_pending.is_empty();
|
||||
let pending = has_pending.then(|| self.world.entities_pending.clone());
|
||||
move |x, z| {
|
||||
levels.block_ticking_at(x, z)
|
||||
&& pending
|
||||
.as_ref()
|
||||
.is_none_or(|p| !p.contains(&(x >> 4, z >> 4)))
|
||||
}
|
||||
}
|
||||
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
|
||||
fn tick_daylight_detector(&mut self, pos: Pos) {
|
||||
|
||||
@@ -1608,6 +1608,16 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LecternBlock.getAnalogOutputSignal`'s `HAS_BOOK` check.
|
||||
pub(super) fn has_book_state(
|
||||
registry: &'static horizon_world::registry::BlockRegistry,
|
||||
state: horizon_world::registry::BlockStateId,
|
||||
) -> bool {
|
||||
registry
|
||||
.state(state)
|
||||
.is_some_and(|s| s.properties.get("has_book").is_some_and(|v| v == "true"))
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`.
|
||||
pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos) -> Option<i32> {
|
||||
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
|
||||
@@ -1721,6 +1731,7 @@ mod lectern_signal_tests {
|
||||
items::ItemStack,
|
||||
};
|
||||
use horizon_core::position::BlockPos;
|
||||
use horizon_world::level::LevelAccess;
|
||||
use horizon_world::registry::BlockStateId;
|
||||
|
||||
fn signal(game: &mut super::Game<'_>, book: Option<ItemStack>, page: i32) -> i32 {
|
||||
@@ -1738,6 +1749,55 @@ mod lectern_signal_tests {
|
||||
lectern_signal(&game.world, BlockPos::new(key.0, key.1, key.2)).unwrap()
|
||||
}
|
||||
|
||||
/// `LecternBlock.getAnalogOutputSignal` reads the block entity only while
|
||||
/// the state's `HAS_BOOK` is true.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_lectern_without_the_has_book_state_feeds_no_comparator() {
|
||||
let mut game = server_game();
|
||||
let key = (8, 64, 8);
|
||||
let state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:lectern")
|
||||
.unwrap();
|
||||
game.world.set(key.0, key.1, key.2, state).unwrap();
|
||||
game.world.block_entities.insert(
|
||||
key,
|
||||
BlockEntity::Lectern(Lectern {
|
||||
book: Some(ItemStack::new("minecraft:writable_book", 1)),
|
||||
page: 0,
|
||||
}),
|
||||
);
|
||||
let signal = |game: &mut super::Game<'_>, state| {
|
||||
game.with_level(|_, level, _| {
|
||||
level.block_entity_analog_signal(
|
||||
BlockPos::new(key.0, key.1, key.2),
|
||||
state,
|
||||
horizon_core::geometry::Direction::North,
|
||||
)
|
||||
})
|
||||
};
|
||||
assert_eq!(
|
||||
signal(&mut game, state),
|
||||
0,
|
||||
"the block state says the lectern holds no book"
|
||||
);
|
||||
let registry = game.pack.assets.registry;
|
||||
let mut with_book = registry.state(state).unwrap().clone();
|
||||
with_book
|
||||
.properties
|
||||
.insert("has_book".into(), "true".into());
|
||||
let with_book = registry.id(&with_book).unwrap();
|
||||
game.world.set(key.0, key.1, key.2, with_book).unwrap();
|
||||
assert_eq!(
|
||||
signal(&mut game, with_book),
|
||||
15,
|
||||
"with HAS_BOOK the book's page signal"
|
||||
);
|
||||
}
|
||||
|
||||
/// `LecternBlockEntity.getRedstoneSignal`: `floor(page / (pageCount - 1)
|
||||
/// * 14) + (hasBook() ? 1 : 0)`. `hasBook()` asks for a book-content
|
||||
/// component, so a stack that only sits in the lectern (a non-book item a
|
||||
|
||||
@@ -255,8 +255,8 @@ pub struct UiState {
|
||||
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
|
||||
/// set by `hud_layers` before anything draws.
|
||||
pub room: horizon_client::hud_kit::room::Room,
|
||||
/// Path of an F2 screenshot waiting for its frame.
|
||||
pub screenshot_message: Option<std::path::PathBuf>,
|
||||
/// F2 screenshots not written yet: each reports in chat once its PNG is.
|
||||
pub screenshot_messages: Vec<std::path::PathBuf>,
|
||||
/// The options screens.
|
||||
pub options: horizon_client::options_ui::OptionsUi,
|
||||
/// The prepared resources before resource packs are layered on.
|
||||
@@ -1982,15 +1982,17 @@ impl Game<'_> {
|
||||
if std::fs::create_dir_all(&dir).is_err() {
|
||||
return None;
|
||||
}
|
||||
let name = screenshot_name(&dir);
|
||||
// A screenshot still being written has no file yet: its name is taken all the same.
|
||||
let name = screenshot_name(&dir, &self.ui.screenshot_messages);
|
||||
let renderer = self.renderer.as_mut()?;
|
||||
let path = dir.join(name);
|
||||
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
|
||||
path: path.clone(),
|
||||
size: None,
|
||||
scaled: true,
|
||||
background: true,
|
||||
});
|
||||
self.ui.screenshot_message = Some(path.clone());
|
||||
self.ui.screenshot_messages.push(path.clone());
|
||||
Some(path)
|
||||
}
|
||||
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
|
||||
@@ -2014,12 +2016,23 @@ impl Game<'_> {
|
||||
self.take_screenshot();
|
||||
true
|
||||
}
|
||||
/// After a frame: report a finished F2 screenshot in chat.
|
||||
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
|
||||
let Some(path) = self.ui.screenshot_message.take() else {
|
||||
/// A screenshot written on its thread: F2's reports in chat. Any other was the world
|
||||
/// icon's (`tick_world_icon`): a failure tries again a second later, as
|
||||
/// `tryTakeScreenshotIfNeeded` does.
|
||||
pub(super) fn finish_screenshot(&mut self, saved: horizon_client::gfx::SavedScreenshot) {
|
||||
let Some(at) = self
|
||||
.ui
|
||||
.screenshot_messages
|
||||
.iter()
|
||||
.position(|p| *p == saved.path)
|
||||
else {
|
||||
if let Err(error) = saved.result {
|
||||
log::warn!("Horizon: couldn't save the world icon: {error}");
|
||||
}
|
||||
return;
|
||||
};
|
||||
let message = match result {
|
||||
let path = self.ui.screenshot_messages.remove(at);
|
||||
let message = match &saved.result {
|
||||
Ok(_) => {
|
||||
log::info!("Saved screenshot to {}", path.display());
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
@@ -2055,7 +2068,7 @@ fn chat_box_text(text: &str) -> String {
|
||||
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
|
||||
.collect()
|
||||
}
|
||||
pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) -> String {
|
||||
// Util.getFilenameFormattedDateTime: the local date and time.
|
||||
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
|
||||
let mut n = 1;
|
||||
@@ -2065,7 +2078,8 @@ pub fn screenshot_name(dir: &std::path::Path) -> String {
|
||||
} else {
|
||||
format!("{base}_{n}.png")
|
||||
};
|
||||
if !dir.join(&name).exists() {
|
||||
let path = dir.join(&name);
|
||||
if !path.exists() && !pending.contains(&path) {
|
||||
return name;
|
||||
}
|
||||
n += 1;
|
||||
@@ -2107,6 +2121,25 @@ mod tests {
|
||||
assert!(should_discard_draft(ChatExit::Done, true));
|
||||
}
|
||||
|
||||
/// `Screenshot.getFile` picks the first free name of the second; an F2
|
||||
/// screenshot still being written on its thread has no file yet, so a
|
||||
/// second press in the same second takes the next name, not the same one.
|
||||
#[test]
|
||||
fn a_screenshot_being_written_keeps_its_name() {
|
||||
let dir = std::env::temp_dir().join(format!("horizon-shots-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let first = screenshot_name(&dir, &[]);
|
||||
let second = screenshot_name(&dir, &[dir.join(&first)]);
|
||||
assert_ne!(first, second);
|
||||
// Unless the clock turned to the next second in between.
|
||||
let same_second = second.starts_with(first.trim_end_matches(".png"));
|
||||
assert!(
|
||||
!same_second || second == first.replace(".png", "_2.png"),
|
||||
"{second}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_ids_are_listed_once_per_root() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
|
||||
|
||||
@@ -3831,12 +3831,9 @@ impl Game<'_> {
|
||||
return;
|
||||
}
|
||||
g.spawn = None;
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
|
||||
dim: level,
|
||||
pos,
|
||||
yaw,
|
||||
forced: true,
|
||||
});
|
||||
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
|
||||
level, pos, yaw, pitch,
|
||||
));
|
||||
});
|
||||
}
|
||||
let dim = Text::literal(self.dim.id());
|
||||
|
||||
@@ -644,7 +644,7 @@ impl Game<'_> {
|
||||
.into());
|
||||
}
|
||||
if arg(2)? != "up" {
|
||||
self.pad.keyboard_took_over = true;
|
||||
self.input_keyboard_used();
|
||||
}
|
||||
// The command confirmation takes the keys; none go past it.
|
||||
if self.command_prompt.is_some() {
|
||||
@@ -800,7 +800,7 @@ impl Game<'_> {
|
||||
// controller still holding one keeps it.
|
||||
let options = [self.settings.toggle_crouch, self.settings.toggle_sprint];
|
||||
for (slot, name) in [(0, "key.sneak"), (1, "key.sprint")] {
|
||||
if !self.pad.holds_action(&self.settings.controller, name) {
|
||||
if !self.input_holds(name) {
|
||||
self.toggles.key(
|
||||
slot,
|
||||
horizon_client::keymapping::KeyAction::Release,
|
||||
@@ -1039,6 +1039,7 @@ impl Game<'_> {
|
||||
path: path.trim().into(),
|
||||
size: None,
|
||||
scaled: false,
|
||||
background: false,
|
||||
});
|
||||
self.pending_screenshot = Some(reply.clone());
|
||||
Ok(None)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,7 @@ use horizon_client::mobs::Mob;
|
||||
use horizon_client::mobs::anim::BodyPose;
|
||||
use horizon_cosmetics::account::OwnedEmote;
|
||||
use horizon_cosmetics::{Item, Playing, Slot};
|
||||
use horizon_hooks::players::PlayerModels;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
|
||||
@@ -37,10 +38,15 @@ pub(in crate::game) struct Emotes {
|
||||
|
||||
impl Emotes {
|
||||
/// The emote to tell the service about, while it plays: its id and whether it loops.
|
||||
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
|
||||
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
|
||||
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
|
||||
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
|
||||
Some((p.item.id.clone(), looping))
|
||||
Some((&p.item.id, looping))
|
||||
}
|
||||
|
||||
/// When the emote that pulled the camera back started (the own model's age), while it plays.
|
||||
pub(in crate::game) fn pull(&self) -> Option<f32> {
|
||||
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,23 +65,43 @@ impl BodyPose for EmotePose {
|
||||
}
|
||||
}
|
||||
|
||||
/// The emote `m` plays, if any.
|
||||
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
|
||||
/// The emote a model's pose plays, if it is one.
|
||||
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
|
||||
let any: &dyn std::any::Any = &**pose?;
|
||||
any.downcast_ref::<EmotePose>().map(|e| &e.0)
|
||||
}
|
||||
|
||||
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
let same = match (playing(m), &next) {
|
||||
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
|
||||
/// it; a new one allocates).
|
||||
fn replaced(
|
||||
now: Option<&Arc<dyn BodyPose>>,
|
||||
next: Option<Playing>,
|
||||
) -> Option<Option<Arc<dyn BodyPose>>> {
|
||||
let same = match (pose_playing(now), &next) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !same {
|
||||
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
|
||||
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
|
||||
}
|
||||
|
||||
/// Makes `m` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
|
||||
if let Some(pose) = replaced(m.emote.as_ref(), next) {
|
||||
m.emote = pose;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes model `id` play `next`, unless it plays that already.
|
||||
pub(in crate::game) fn set_model_playing(
|
||||
models: &mut dyn PlayerModels,
|
||||
id: u32,
|
||||
next: Option<Playing>,
|
||||
) {
|
||||
if let Some(pose) = replaced(models.body_pose(id), next) {
|
||||
models.set_body_pose(id, pose);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +255,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Once a tick: my emote, then the other players'.
|
||||
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
|
||||
pub(in crate::game) fn tick_emotes(&mut self) {
|
||||
if let Some(done) = self
|
||||
.cosmetics
|
||||
@@ -250,20 +276,6 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
self.tick_emote();
|
||||
self.tick_others_emotes();
|
||||
}
|
||||
|
||||
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
|
||||
/// out over the emote's first quarter second.
|
||||
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
|
||||
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
|
||||
return 1.0;
|
||||
};
|
||||
if self.cosmetics.emotes.camera_from.is_none() {
|
||||
return 1.0;
|
||||
}
|
||||
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
|
||||
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,8 +314,7 @@ impl Game<'_> {
|
||||
light = FULL_LIGHT;
|
||||
}
|
||||
let mut poses = self.cosmetics.poses.borrow_mut();
|
||||
let poses = &mut poses.1;
|
||||
self.pose_companion(item, m, alpha, poses, &model);
|
||||
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
|
||||
self.pose_companion(item, m, alpha, &mut poses, &model);
|
||||
self.cosmetics_model(&model, matrix, &poses, uv, light, false, None, out);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ impl Game<'_> {
|
||||
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
|
||||
keybinds::key_code(self.settings.keys.get(mapping))
|
||||
.is_some_and(|k| self.physical_keys.contains(&k))
|
||||
|| self.pad.holds_action(&self.settings.controller, mapping)
|
||||
|| self.input_holds(mapping)
|
||||
}
|
||||
|
||||
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
|
||||
|
||||
@@ -2317,7 +2317,7 @@ impl Game<'_> {
|
||||
a.spawn_velocity = Some(p.velocity);
|
||||
a.spawn_rot = Some((p.pitch, p.yaw));
|
||||
}
|
||||
self.creatures.projectiles.push(p);
|
||||
self.creatures.projectiles.put_back(p);
|
||||
self.native_projectile_launch(player, &name, id, kind);
|
||||
}
|
||||
/// `MushroomCow.shear`'s `sendParticles(EXPLOSION, x, getY(0.5), z, 1, 0, 0, 0, 0)`:
|
||||
@@ -6949,7 +6949,7 @@ impl Game<'_> {
|
||||
{
|
||||
m.heal(25.0);
|
||||
consume = true;
|
||||
sound = Some("entity.iron_golem.repair");
|
||||
eat = Some(("entity.iron_golem.repair", 1.0, m.golem_repair_pitch()));
|
||||
}
|
||||
Species::Creeper if creeper_igniter => {
|
||||
m.extra.flag = true;
|
||||
|
||||
@@ -280,25 +280,18 @@ impl Game<'_> {
|
||||
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
|
||||
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
|
||||
}
|
||||
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
|
||||
// with it.
|
||||
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
|
||||
// posed on the GPU with the mob's own jobs: culled with it.
|
||||
if !layers_only
|
||||
&& super::super::cosmetics::enabled()
|
||||
&& horizon_hooks::engine::any()
|
||||
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
|
||||
{
|
||||
let mark = self.skin_frame.borrow().mark();
|
||||
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
|
||||
let gpu = skinning;
|
||||
if let Some((lo, hi)) = self.cosmetics_geometry(
|
||||
mob,
|
||||
false,
|
||||
base,
|
||||
Some(camera),
|
||||
alpha as f32,
|
||||
packed,
|
||||
gpu,
|
||||
out,
|
||||
) {
|
||||
if let Some((lo, hi)) =
|
||||
self.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
|
||||
{
|
||||
skinned = Some(match skinned {
|
||||
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
|
||||
Some((first, None)) => (first, Some((lo, hi))),
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
//! 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) {
|
||||
// One Discord status per computer: P1's, not a splitscreen player's.
|
||||
if self.options.split_guest {
|
||||
return;
|
||||
}
|
||||
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(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
}
|
||||
} else if let Some(server) = &self.integrated {
|
||||
match (server.published(), &self.remote) {
|
||||
(Some(_), Some(remote)) => Play::Hosting {
|
||||
players: remote.player_count(),
|
||||
max: horizon_client::lan::MAX_PLAYERS,
|
||||
},
|
||||
_ => 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,7 +353,7 @@ impl Game<'_> {
|
||||
let found =
|
||||
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
|
||||
used.push(match found {
|
||||
Some((p, yaw)) => format!(
|
||||
Some((p, yaw, _pitch)) => format!(
|
||||
"respawn {} {} {} {}",
|
||||
num(p.x),
|
||||
num(p.y),
|
||||
@@ -579,6 +579,7 @@ impl Game<'_> {
|
||||
dim: self.dim,
|
||||
pos: [p(0), p(1), p(2)],
|
||||
yaw: 0.0,
|
||||
pitch: 0.0,
|
||||
forced: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -739,7 +739,8 @@ impl Game<'_> {
|
||||
// anchor puts the player, facing it; else onto the spawn column.
|
||||
let mut target = target;
|
||||
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
|
||||
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
|
||||
// This existing owner transition adapter carries yaw only; its pitch path remains open.
|
||||
if let Some((at, yaw, _pitch)) = self.find_respawn_and_use(c, false)? {
|
||||
return Ok(Some((at, Some(yaw))));
|
||||
}
|
||||
target = fallback_spawn(self.spawn, self.level.world_spawn);
|
||||
|
||||
@@ -1008,7 +1008,10 @@ impl Game<'_> {
|
||||
p.position.z - center[2],
|
||||
];
|
||||
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
|
||||
self.pad_rumble(horizon_client::gamepad::Rumble::Explosion { distance, radius });
|
||||
self.input_feedback(horizon_client::input_source::Feedback::Explosion {
|
||||
distance,
|
||||
radius,
|
||||
});
|
||||
}
|
||||
// Explosion.explode: the blast is heard.
|
||||
let ctx = horizon_client::sculk::Context::default();
|
||||
|
||||
@@ -70,9 +70,10 @@ fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
|
||||
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
|
||||
assert_eq!(game.respawn_config.is_some(), allowed);
|
||||
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
|
||||
if let Some((at, yaw)) = actual {
|
||||
if let Some((at, yaw, pitch)) = actual {
|
||||
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
|
||||
assert_eq!(yaw, 0.0);
|
||||
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +135,8 @@ impl Game<'_> {
|
||||
}
|
||||
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
|
||||
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
|
||||
if self.studio_freecam.active {
|
||||
return self.studio_freecam.fov.clamp(1.0, 179.0);
|
||||
if self.studio.flying() {
|
||||
return self.studio.freecam.fov.clamp(1.0, 179.0);
|
||||
}
|
||||
let fov = self.view.calculate_fov(
|
||||
partial,
|
||||
|
||||
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user