Compare commits

..
Author SHA1 Message Date
Horizon agent ec9c65be87 Merge remote-tracking branch 'origin/main' into layers/packs-entity
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 23:38:56 +01:00
Ludovic Marie cdfbac97bd Merge pull request #14056 from horizon-rust/layers/packs-polytone-1
🤖 Agent: a pack's own options move to the engine crate horizon-pack-options with no dependencies; Polytone's config reading reaches it only through pack format hooks. layer plan M6 step 8.
2026-11-09 20:59:07 +01:00
Ludovic Marie d237f961f8 Merge pull request #14040 from horizon-rust/layers/packs-cem-mobs
🤖 Agent: custom entity models bake once per pack load behind the engine's model hooks; the mob hook lives in horizon-client's mob_formats glue. layer plan M6 step 7.
2026-11-09 20:55:04 +01:00
Horizon agent c8701964f4 Merge branch 'layers/packs-cem-mobs' into layers/packs-polytone-1
# Conflicts:
#	crates/horizon-client-render/src/pack_formats/mod.rs
#	crates/horizon-client-render/src/pack_hooks.rs
2026-11-09 18:57:34 +01:00
Horizon agent 6a8d226dde Merge branch 'layers/packs-entity' into layers/packs-cem-mobs 2026-11-09 18:57:06 +01:00
Horizon agent 2c8ae8f2ef Merge branch 'layers/packs-cit' into layers/packs-entity 2026-11-09 18:57:05 +01:00
Horizon agent 646a049d2d Merge branch 'layers/packs-ctm' into layers/packs-cit 2026-11-09 18:57:04 +01:00
Horizon agent 717fe15087 Merge branch 'layers/packs-emissive' into layers/packs-ctm
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/pack_formats/mod.rs
2026-11-09 18:57:02 +01:00
Horizon agent c7ba820eac Merge branch 'layers/packs-cem-mobs' into layers/packs-polytone-1 2026-11-09 18:03:54 +01:00
Horizon agentandClaude Opus 5.5 2ebefd515c CEM: the moved animation tests import in_gui
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:03:19 +01:00
Horizon agent 8ca8cf121b Merge branch 'layers/packs-cem-mobs' into layers/packs-polytone-1 2026-11-09 17:38:13 +01:00
Horizon agent 9eff1ee00a Merge branch 'layers/packs-entity' into layers/packs-cem-mobs 2026-11-09 17:38:13 +01:00
Horizon agent 26d9efa842 Merge branch 'layers/packs-cit' into layers/packs-entity 2026-11-09 17:38:12 +01:00
Horizon agentandClaude Opus 5.5 48aae09c66 Drop an unused trait import
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:38:12 +01:00
Horizon agent f70fb7da5d Merge branch 'layers/packs-cem-mobs' into layers/packs-polytone-1 2026-11-09 17:35:02 +01:00
Horizon agent 99c9f7eaa5 Merge branch 'layers/packs-entity' into layers/packs-cem-mobs 2026-11-09 17:35:01 +01:00
Horizon agent 6ddd66576d Merge branch 'layers/packs-cit' into layers/packs-entity
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:35:00 +01:00
Horizon agent 89564ffce8 Merge branch 'layers/packs-emissive' into layers/packs-ctm 2026-11-09 17:34:47 +01:00
Horizon agent fb9b6d4299 Merge branch 'layers/packs-ctm' into layers/packs-cit 2026-11-09 17:34:47 +01:00
Horizon agentandClaude Opus 5.5 87d4324eb6 Keep the reshaped models type beside the hook
block_entity_render.rs is over 3000 lines and may not grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:54 +01:00
Horizon agentandClaude Opus 5.5 16d2f19957 Expressions read pack options through config_value
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:25:03 +01:00
Horizon agentandClaude Opus 5.5 bb090e827e Pack options through an engine crate and pack format hooks
The options a resource pack declares (Polytone config entries) move to
horizon-pack-options, an engine crate with no dependencies that
horizon-polytone re-exports. Reading them from a pack, the overlay
conditions and the sounds.json filter go through new PackFormats hooks,
so packs.rs, options_ui.rs and pack_options_ui.rs no longer name
horizon_polytone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:19:24 +01:00
Horizon agentandClaude Opus 5.5 8426465800 Format the custom entity model split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:18:12 +01:00
Horizon agent 427729f784 Merge branch 'layers/packs-entity' into layers/packs-cem-mobs 2026-11-09 17:18:05 +01:00
Horizon agentandClaude Opus 5.5 6a495df673 Format the entity texture split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:18:04 +01:00
Horizon agent 9eb3dc457b Merge branch 'layers/packs-cit' into layers/packs-entity 2026-11-09 17:17:56 +01:00
Horizon agentandClaude Opus 5.5 975b29a641 Format the custom item texture split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:55 +01:00
Horizon agentandClaude Opus 5.5 23f1b63e9c Mob custom entity models through an engine extension point
mobs/cem.rs (engine) keeps CemModels, CemLayer with a boxed LayerAnimation
(one call per animated layer per draw), the standing-block gate and its own
ModelFormats hook; mobs/cem_anim.rs keeps the per-frame facts (Frame,
set_frame, in_gui). The .jem reader, builder and expression animations move
to mob_formats/ (platform code staged in horizon-client-lib), installed by
entry::main.

Layer plan M6 step 7.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:23 +01:00
Horizon agentandClaude Opus 5.5 8aec64cc36 Block entity custom models through an engine extension point
block_entity_cem.rs moves to pack_formats/; the renderer asks
PackFormats::block_entity_models for a pack's reshaped models. Reshaped
moves to block_entity_render.

Layer plan M6 step 7.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:25 +01:00
Ludovic Marie 0aa44ff207 Merge branch 'layers/packs-cit' into layers/packs-entity 2026-11-09 16:46:40 +01:00
Ludovic Marie 8a00ab490a Merge branch 'layers/packs-ctm' into layers/packs-cit 2026-11-09 16:46:40 +01:00
Ludovic Marie d486e88b5b Merge branch 'layers/packs-emissive' into layers/packs-ctm 2026-11-09 16:46:39 +01:00
Horizon agentandClaude Opus 5.5 b6c527fb8c Entity texture variants, blinks and emissive overlays through an engine extension point
entity_textures.rs (engine) holds the tables drawing looks up (variants by
texture, blink frames, emissive overlays) and the facts a variant rule asks;
the rule files sit behind entity_textures::VariantRules, asked only when a mob
chooses its variants. pack_formats/entity_textures.rs fills them from
horizon_optifine's scans (PackFormats::entity_textures). The pack-loading
benches install the formats as the game does.

Layer plan M6 step 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:43:24 +01:00
Horizon agentandClaude Opus 5.5 88cbb484fd Custom item textures through an engine extension point
The engine's cit.rs now holds what it draws (Rule, Kind, Hand, Glint, Blend,
Factor) and a CitRules table with a boxed Matcher the pack formats fill once
per pack load (pack_hooks::PackFormats::item_textures). The OptiFine/CIT
Resewn reader, matcher and glint composition move to pack_formats/cit.rs and
pack_formats/cit_glint.rs; cit_glint keeps StackGlint and its cycle timing.

Layer plan M6 step 5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:38:06 +01:00
Horizon agentandClaude Opus 5.5 d3e7582474 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:29 +01:00
Horizon agent 08a3a5e8ef Merge branch 'layers/packs-emissive' into layers/packs-ctm 2026-11-09 16:27:29 +01:00
Horizon agentandClaude Opus 5.5 32bd0123e2 Connected textures through the pack-format hook, meshed statically
layer plan M6 (pack formats), step 4: the mesher draws connected textures
through an engine trait (connected::ConnectedTextures) it is generic over:
mesh_reusing calls the pack format's mesh entry point once per section,
which runs Mesher::mesh_with on its own type, so faces call pick
statically (no dynamic dispatch per face). OptiFine's CTM and Fusion's
rules move to pack_formats/ unchanged; the engine keeps the mesher's
vocabulary (Pick, TileLayer, Overlay, Face).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:55:15 +01:00
282 changed files with 9580 additions and 23785 deletions

No files matched your search

-34
View File
@@ -1,34 +0,0 @@
name: runner watch
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
# gh workflow run "runner watch" -f dry_run=true
on:
schedule:
- cron: '11,41 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with the issue
type: boolean
default: false
permissions:
actions: read
contents: read
issues: write
concurrency:
group: runner-watch
cancel-in-progress: false
jobs:
watch:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Jobs waiting for a self-hosted runner
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
+1
View File
@@ -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 |
Generated
+6
View File
@@ -1590,6 +1590,7 @@ dependencies = [
"horizon-items",
"horizon-narrator",
"horizon-optifine",
"horizon-pack-options",
"horizon-polytone",
"horizon-protocol",
"horizon-shader-api",
@@ -1831,6 +1832,10 @@ dependencies = [
"regex",
]
[[package]]
name = "horizon-pack-options"
version = "0.1.0"
[[package]]
name = "horizon-platform"
version = "0.1.0"
@@ -1848,6 +1853,7 @@ dependencies = [
name = "horizon-polytone"
version = "0.1.0"
dependencies = [
"horizon-pack-options",
"log",
"regex",
"serde_json",
+1
View File
@@ -47,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",
+45 -145
View File
@@ -2,9 +2,6 @@
> 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).
>
> Amended 2026-11-10 by the founder (three kinds of action, elections by tier, who may do what, and council poll P41:
> the mods decide moderation matters). The council can overturn this amendment by poll.
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
@@ -53,13 +50,13 @@ 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; vote in their group's elections (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; vote in the group's elections and votes when active | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | Given by any moderator (a free action, section 2.1) | Resign, 30 days inactive, or removed by a moderator, the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | Elected by the mods (section 5.3); council veto within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, recalled by the 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 | Elected by the mods, from the mods with at least 30 days (section 5.3); council veto within 24 h | Resign, 30 days inactive, recalled by the mods, 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 | Elected by the group's active members and the Team (section 5.3); council veto within 24 h; 30 days probation | Resign, 30 days inactive (automatic), recalled by the same electorate, 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 | Elected by the Team, 2/3 (section 5.3); council veto within 24 h; 30 days probation | Resign, 60 days without a merge or review (automatic), recalled by the Team (2/3), council poll (2/3, without the person) | **Yes** |
| **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
@@ -67,55 +64,6 @@ owner, Discord server owner, the Microsoft app approved by Mojang, the domains,
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.
### 2.1 Three kinds of action
Most things need no vote at all. A vote is for power over other people, and for decisions that bind a whole body.
| Kind | What | How |
| --- | --- | --- |
| **Free** | Everyday actions inside your own role (the list below, and anything of the same weight) | Just do it. Kira logs the ones she sees in #staff-log. The tier above can reverse it; if people disagree, the tier above votes. A pattern of abuse is a recall |
| **Body vote** | Giving or taking a role that carries power (an election), or a decision that belongs to one body: the mods, the Team, a group | `/elect` or `/vote body:` in Kira (section 5.3). Any council member can call a veto within 24 hours |
| **Council poll** | Rules, money, accounts and ownership, the council's own members, vetoes, anything that spans several bodies | `/poll` (section 4) |
Free actions, for example:
- a lead gives or removes their group's helper role (🧩 Horizon Modding Team), through Kira;
- a moderator gives or removes 🤝 Support, through Kira;
- a moderator times out, deletes, quarantines, or changes a channel's slowmode or topic; the Chief of Mods bans and
unbans;
- the Team merges and labels;
- a server operator restarts the official server, or prunes backups and chunks by the written rule;
- a lead pins, renames or creates threads in their group's channels;
- Kira's automatic actions.
**The 24-hour rule.** Every vote lasts 24 hours at most, and closes earlier once its result can't change. Only council
polls on this document, the licence, money and the GitHub organisation's ownership last 48 hours. A veto is called
within 24 hours of a result.
### 2.2 Who may do what
What each post decides, and what it holds on Discord, on the official server and on GitHub. Anything not listed is a
council matter.
| Post | Decides | Discord | Official server | GitHub |
| --- | --- | --- | --- | --- |
| **Member** | — | Chat, threads, picks group roles | Play | Issues, pull requests, `/claim` |
| **Group helper** (🧩 Horizon Modding Team) | — | Writes in the group's team channel | — | — |
| **🤝 Support** | Closes support posts | Manages threads in #horizon-support | — | — |
| **🛡️ Moderator** | Moderation matters, by the mods' vote (section 7) | Time out, delete messages, manage threads and nicknames, audit log, voice mute and move; quarantine through Kira; the staff channels | In-game op if also elected operator | — |
| **🛡️ Chief of Mods** | A moderator's, plus bans and unbans; suspends a mod at once; breaks ties in the mods' votes | Plus Ban | As a moderator | — |
| **Server operator** | In-game moderation by the server rules | — | Op (the server's ops list); no panel, no `server.toml` | — |
| **🧭 Server & SMP lead** | The server's settings, by the written rules (backup first, warning in game) | As every lead | A panel sub-user once a council accounts poll gives one | As every lead |
| **🧭 Group lead** | The group's free actions; gives and removes its helper role | In the group's channels: delete messages, manage and pin threads, ping the group; the helper role through Kira | — | Triage on the group's labels through Kira (no repository role) |
| **🌌 Horizon Team** | Merges; elects Team members | #horizon-team and the staff channels. The role still carries timeout and delete; whether it keeps them is a mod matter | — | `horizon-team`: write on horizon, horizon-mods, horizon-mixins, horizon-native-mod-template and horizon-server; also kira and horizon-site if P37 passes |
| **🔧 Server Manager** | — | Manage Channels and Manage Roles (the server's layout); given by council poll | — | — |
| **🏛️ Council** | Council polls, vetoes, emergencies (any 2) | A moderator's permissions plus Ban, mention @everyone and pin; #council | Panel sub-users for two members if P38 passes | The `council` team: code-owner reviews and the release tools' owner powers if P35 passes, security managers if P36 passes; a second organisation owner if P28 passes |
| **Account holder** (the founder) | Carries out decisions on his accounts (section 11) | Server owner | The panel's owner account | Organisation owner |
Kira applies a passed election everywhere she can: Discord roles herself, GitHub team membership when the council has
given her a token for it. Whatever she can't do (a panel sub-user, the ops list, a GitHub team without a token) she
posts as a list of clicks for a named person.
## 3. Each level in detail
### Member
@@ -136,13 +84,13 @@ an issue or a pull request on GitHub, or a request for a council poll (any counc
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. Active group members vote in the
group's elections and decisions (section 5.3).
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. A moderator gives it, and takes it back, through Kira.
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
@@ -203,7 +151,7 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
| --- | --- |
| 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; 48 hours for this document, the licence, money and the GitHub organisation's ownership. It closes early only if every council member has voted |
| 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 |
@@ -217,20 +165,20 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
| Vetoing a body's vote or election (section 5.3) | Removing a council member or a Team member (without the person concerned) |
| Removing a group lead, a moderator, the Chief of Mods or Support (their bodies can also recall them) | Rules: this document, the server rules, the contributing policy, the licence |
| 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) |
| Deciding for a body that has fewer than 3 voters | Waiving part of the selection filter (section 5.4) |
| 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 start with a **24-hour public discussion**. Other polls, and elections, are
discussed in their own thread while they run. A member who wants more time votes No and says
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
@@ -272,7 +220,7 @@ false age declaration (section 9).
## 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. Their body then elects them (section 5.3).
measures the work, and only people above a floor can be nominated. The council then decides among them.
### 5.1 What Kira measures
@@ -306,63 +254,25 @@ A nomination is refused below these floors.
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 Elections
### 5.3 The path to a role
The body that works with a person elects them. Kira runs it: `/elect post: member: [group:]`, and `action: remove`
for a recall.
| Post | Who votes | Passes with | Who can start it |
| --- | --- | --- | --- |
| **🛡️ Moderator** | The mods (🛡️ Mod and 🛡️ Chief of Mods) | Simple majority; the Chief of Mods breaks a tie | Any mod, a council member |
| **🛡️ Chief of Mods** | The mods; candidates are mods for 30 days or more | Simple majority | Any mod, a council member |
| **Server operator** | The mods and the 🧭 Server lead | Simple majority | The Server lead, the Chief of Mods, a council member |
| **🧭 Group lead** (a co-lead once the group has 8 active members) | The group's active members, and the Team | Simple majority | The current lead, a Team member, a council member |
| **🌌 Horizon Team** | The Team | Two thirds, and at least 3 Yes | A Team member, a council member |
| **🏛️ Council** | The council, by poll after a 24-hour discussion (section 4) | Two thirds, and at least 3 Yes | A council member |
| 🤝 Support, a group's helper role | Nobody: a free action (section 2.1) | — | A moderator; the group's lead |
An **active group member** has held the group's role for 14 days or more (Kira counts from when she first saw it)
and has at least one merged pull request or review with the group's label in the last 60 days.
1. **Start.** Kira checks the floor (5.2) and the 18+ declaration. If the candidate falls short, the person starting
gets a private "not now", and the candidate a private message saying what is missing. Nothing is posted and no
reason is ever shown to anyone else. Kira checks what she can measure (verified GitHub, merged pull requests and
reviews, overall and by the group's label, rank, days in the server, Kira level, clean record, the declaration);
the voters judge the rest.
2. **Vote, 24 hours,** in the body's channel, discussed in the vote's thread. Yes, No or Abstain, changeable while it
runs. Kira shows how many voted, never who. It closes early once the result can't change.
3. **Result,** applied at once. Kira gives or takes the Discord role and, with a token, the GitHub team; she lists the
rest as clicks for a named person. Only the result is published, never the ballots.
4. **Veto, 24 hours.** Any council member can call it with the button under the result. A council poll (simple
majority, without the people involved) decides; if it passes, Kira undoes the result.
5. **Probation, 30 days,** for leads and Team members. It ends by itself unless someone who can vote starts a recall.
The council can always remove someone by its own poll. **Body votes** work the same way for a body's own decisions:
`/vote body:mods`, `body:team` or `body:group`, simple majority, 24 hours, the same veto.
#### Hard cases
| Case | What happens |
| --- | --- |
| Fewer than 3 can vote | The council decides by poll |
| Quorum | A third of the electorate, and at least 3 ballots (Abstain counts). Without it, nothing changes |
| Tie | Fails, and nothing changes. In the mods' votes, the Chief of Mods' ballot breaks it |
| The candidate | Doesn't vote on themselves, and can decline (the election ends). The person who started it votes normally |
| Conflicts | Whoever the vote is about, or who gave or received the moderation action in question, doesn't vote |
| Several candidates for one seat | Nominations within the first 12 hours join one approval ballot. The seat goes to the most approvals if that is at least half of the voters; otherwise a 24-hour run-off between the top two |
| A seat that is held | Changes only by a recall or by the 6-month confirmation |
| The council is already voting on it | No election starts for the same person and post |
| Cooldown | A failed or vetoed election can't be started again for the same person and post for 30 days. At most 3 elections run at once in a body |
| Vacancy | When a lead or the Chief of Mods leaves, Kira opens the seat's election herself: 24 hours of nominations, then the vote with everyone nominated. Nobody nominated: the seat stays open, and the tier above acts meanwhile. Kira never picks a candidate |
| Abuse of a free action | The tier above reverses it; a disagreement goes to its vote. A pattern is a recall |
| Emergency | Section 4.6. The Chief of Mods can still suspend a mod at once |
| Kira is down | The body may run the same vote as a Discord poll in its channel for 24 hours; Kira records the result when she is back |
#### Reminders, not nominations
Once a month in a group's channel, Kira says how to propose someone, only when the group has no lead or someone in it
meets a post's floor. When a seat opens, she posts once in the electorate's channel. Once a month in #council, she
lists privately the people who meet a floor. These are suggestions: only people start elections.
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
@@ -389,8 +299,8 @@ holder or an org owner removes GitHub access when the Team role ends.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by an election in their group. If someone else in the group passes the
floor and is nominated within its first 12 hours, both go on the same ballot.
- **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).
@@ -429,9 +339,8 @@ the design system:
- 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;
- giving and removing the group's helper role, through Kira (a free action);
- 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 starting elections in the group.
- asking the Team for reviews, adding `needs-proof`, and nominating people.
### 6.4 What goes to the council
@@ -447,28 +356,21 @@ the design system:
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 posts once that the seat is open and how to start its election (section 5.3). **No lead is better than a lead who doesn't do the work:**
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 mods decide moderation matters** (council poll P41, imposed by the founder on 2026-11-10): who joins or
leaves the mod team, the mods' permissions and the staff channels, bans and unbans, and channel settings for
moderation. Everyday actions are free (section 2.1). Anything bigger is a mods' vote (`/vote body:mods`, simple
majority, 24 hours), and the Chief of Mods breaks ties. **The council keeps a veto:** within 24 hours of a mods'
decision, any council member can call a council poll, and a simple majority overturns it.
- **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 mods' vote in #mod-chat. Mods who were
involved don't vote, and the council's veto applies. The answer is final for 90 days.
- **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 mods decide within 72 hours by
a recall vote. The mods recall the Chief of Mods the same way. The council can also remove either by poll.
- **Council members keep the ban permission** for emergencies and when no moderator is around. They log the ban like
any other, and the mods can reverse it by vote.
- **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
@@ -556,9 +458,7 @@ and the founder carries it out.
## 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 results of elections
and body votes are posted there too; for elections, only the result.
- **#staff-log:** the free actions Kira sees (roles given or taken, bans, unbans), so the tier above can check them.
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
+2 -95
View File
@@ -70,8 +70,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
other one 30 000 characters long), flood-book (100 pages of 1024 characters
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--ack-mode MODE chunk batch feedback: vanilla (default) or 64 (fixed chunks per tick);
one ACK per completed batch; use explore for a controlled pacing test
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
@@ -173,8 +171,6 @@ struct Options {
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
/// None uses vanilla estimation; --ack-mode 64 requests a fixed rate.
ack_rate: Option<f32>,
no_read: bool,
no_loaded: bool,
height: f64,
@@ -213,7 +209,6 @@ fn options() -> Result<Options, String> {
no_swing: false,
crits: false,
flood: 100,
ack_rate: None,
no_read: false,
no_loaded: false,
height: 24.0,
@@ -271,13 +266,6 @@ fn options() -> Result<Options, String> {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
"--ack-mode" => {
o.ack_rate = match value.as_str() {
"vanilla" => None,
"64" => Some(64.0),
_ => return Err(format!("--ack-mode {value}: use vanilla or 64")),
};
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
@@ -962,7 +950,6 @@ fn read(
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
ack_rate: Option<f32>,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
@@ -1048,8 +1035,7 @@ fn read(
batch.finished(r.varint().unwrap_or(0));
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: ack_rate
.unwrap_or_else(|| batch.desired_chunks_per_tick()),
chunks_per_tick: batch.desired_chunks_per_tick(),
})
.encode(),
)
@@ -1153,8 +1139,7 @@ fn run_bot(
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
let ack_rate = o.ack_rate;
thread::spawn(move || read(reader, writer, bot, track, ack_rate, capture));
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1534,7 +1519,6 @@ fn main() {
"behaviour": o.behaviour,
"seed": o.seed,
"explore_speed": o.explore_speed,
"ack_mode": o.ack_rate.map_or("vanilla", |_| "64"),
"pvp": o.pvp,
"view_distance": o.view_distance,
"seconds": o.seconds,
@@ -1600,83 +1584,6 @@ mod tests {
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
};
/// Exercise the production reader over framed connections. Pings are barriers
/// so extra ACKs are observable without sleeps or timing guesses.
fn check_chunk_acks(ack_rate: Option<f32>) {
use horizon_protocol::connection::Pipe;
let (client, server) = Pipe::pair();
let (reader, writer) = Connection::local(client).split();
let mut server = Connection::local(server);
server
.reader
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let bot = Arc::new(Bot::default());
bot.online.store(true, Ordering::SeqCst);
let handle = thread::spawn(move || {
read(
reader,
Arc::new(Mutex::new(writer)),
bot,
false,
ack_rate,
None,
)
});
for (nonce, size) in [0, 1, 5].into_iter().enumerate() {
server
.send(&raw(ids::play::clientbound::CHUNK_BATCH_START, |_| {}))
.unwrap();
server
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(
id,
ids::play::serverbound::PONG,
"batch start must not send an ACK"
);
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
server
.send(&raw(ids::play::clientbound::CHUNK_BATCH_FINISHED, |w| {
w.varint(size)
}))
.unwrap();
server
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(id, ids::play::serverbound::CHUNK_BATCH_RECEIVED);
let rate = Reader::new(&body).f32().unwrap();
if let Some(fixed) = ack_rate {
assert_eq!(rate, fixed);
} else if size == 0 {
assert_eq!(rate, 3.5, "vanilla initial rate must remain the default");
} else {
assert!(rate.is_finite() && rate > 0.0);
}
let (id, body) = server.recv().unwrap();
assert_eq!(
id,
ids::play::serverbound::PONG,
"exactly one ACK per finished batch"
);
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
}
server.writer.shutdown();
handle.join().unwrap();
}
#[test]
fn fixed_64_ack_is_sent_once_per_finished_batch() {
check_chunk_acks(Some(64.0));
}
#[test]
fn vanilla_acks_remain_once_per_finished_batch() {
check_chunk_acks(None);
}
fn feed(entities: &mut Entities, packet: &Clientbound) {
let bytes = packet.encode();
let mut r = Reader::new(&bytes);
@@ -514,34 +514,6 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_fallback_references_stay_bounded() {
// 300 references to the argument, nested three deep: 27 million copies of "A" from a
// 4 KB component, which ran a client out of memory.
let bomb = |depth: usize, text: &str| {
let pattern = "%1$s".repeat(300);
let mut v = serde_json::json!({ "text": text });
for _ in 0..depth {
v = serde_json::json!({"translate": "x.unknown", "fallback": pattern, "with": [v]});
}
v
};
let started = std::time::Instant::now();
let text = to_text(&bomb(3, "A"), &mut |s| Text::literal(s));
assert!(text.spans.len() < 1 << 15, "{} spans", text.spans.len());
// One long argument referenced many times multiplies bytes, not spans.
let long = "B".repeat(64 << 10);
let text = to_text(&bomb(1, &long), &mut |s| Text::literal(s));
let bytes: usize = text.spans.iter().map(|s| s.text.len()).sum();
assert!(bytes <= 4 << 20, "{bytes} bytes");
assert!(started.elapsed() < std::time::Duration::from_secs(5));
// A real fallback still formats.
let ok = serde_json::json!({
"translate": "x.unknown", "fallback": "%1$s and %1$s", "with": [{"text": "A"}]
});
assert_eq!(to_text(&ok, &mut |s| Text::literal(s)).plain(), "A and A");
}
use crate::lang::{self, Language, ResourceSource};
struct Mem(std::collections::HashMap<String, Vec<u8>>);
+1 -19
View File
@@ -529,13 +529,10 @@ impl Team {
None => t,
}
}
/// `PlayerTeam.getFormattedDisplayName`: the display name in the team's
/// `displayNameStyle`, which its constructor makes the team's name on hover
/// (`ShowText`) and as insertion.
/// `PlayerTeam.getFormattedDisplayName`.
pub fn formatted_display_name(&self) -> Text {
let inner = hover_name(comp_text(&self.display_name), &self.name).with_style(&Style {
click: None,
insertion: Some(self.name.clone()),
..Style::default()
});
self.apply_color(bracketed(inner))
@@ -1414,21 +1411,6 @@ fn finish(node: &mut serde_json::Map<String, Value>, extra: Vec<Comp>) -> Comp {
#[cfg(test)]
mod tests {
use super::*;
/// #14279: `PlayerTeam`'s `displayNameStyle` puts the team's name on hover
/// and as insertion; `getFormattedDisplayName` brackets the name in it.
#[test]
fn a_teams_formatted_name_inserts_the_team_name() {
let team = Team::new("reds");
let c = team.formatted_display_name().component();
assert_eq!(c["translate"], "chat.square_brackets");
let inner = &c["with"][0];
assert_eq!(inner["text"], "reds");
assert_eq!(inner["insertion"], "reds");
assert_eq!(inner["hover_event"]["action"], "show_text");
// `{"text":"reds"}` or its short form `"reds"`: the same component.
let value = &inner["hover_event"]["value"];
assert!(value == "reds" || value["text"] == "reds", "{value}");
}
fn board() -> Scoreboard {
let mut sb = Scoreboard::default();
+11 -69
View File
@@ -282,20 +282,17 @@ impl Text {
pub fn push(&mut self, other: impl Into<Text>) {
let other = other.into();
let sibling = other.component();
// The tree is appended to in place: cloning all of it on every append made a component
// with N siblings (a server's chat or MOTD "extra") cost N^2.
let root = match self.source.take() {
None if self.spans.is_empty() => std::sync::Arc::new(json!({ "text": "" })),
None => std::sync::Arc::new(self.component()),
Some(c) => c,
};
let mut root = if STYLE_KEYS.iter().any(|k| root.get(*k).is_some()) {
let c = std::sync::Arc::try_unwrap(root).unwrap_or_else(|c| (*c).clone());
std::sync::Arc::new(json!({ "text": "", "extra": [c] }))
let mut root = if self.spans.is_empty() && self.source.is_none() {
json!({ "text": "" })
} else {
root
let c = self.component();
if STYLE_KEYS.iter().any(|k| c.get(*k).is_some()) {
json!({ "text": "", "extra": [c] })
} else {
c
}
};
if let Value::Object(o) = std::sync::Arc::make_mut(&mut root) {
if let Value::Object(o) = &mut root {
match o.get_mut("extra") {
Some(Value::Array(a)) => a.push(sibling),
_ => {
@@ -303,7 +300,7 @@ impl Text {
}
}
}
self.source = Some(root);
self.source = Some(root.into());
self.spans.extend(other.spans);
}
/// Apply `style` underneath every span (MutableComponent.withStyle on a parent).
@@ -674,22 +671,11 @@ pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
s
}
/// The most spans one translation expands to. Vanilla's `TranslatableContents.decompose` keeps
/// references to its arguments, so its cost stays linear in the input; copying them here makes
/// nested `%1$s` templates multiply (`k` references nested `D` deep: `k^D` copies), and a server's
/// `fallback` template is any text it likes.
const MAX_TRANSLATION_SPANS: usize = 1 << 15;
/// The most text one translation expands to: one long argument referenced many times multiplies
/// bytes, not spans.
const MAX_TRANSLATION_BYTES: usize = 1 << 22;
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
/// shown verbatim, and so is one that would expand past [`MAX_TRANSLATION_SPANS`] or
/// [`MAX_TRANSLATION_BYTES`].
/// shown verbatim.
pub fn format_translation(template: &str, args: &[Text]) -> Text {
let mut out = Text::default();
let mut bytes = 0usize;
let mut literal = String::new();
let mut next = 0usize;
let chars: Vec<char> = template.chars().collect();
@@ -739,14 +725,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
let Some(arg) = args.get(index) else {
return Text::literal(template);
};
bytes = bytes
.saturating_add(literal.len())
.saturating_add(arg.spans.iter().map(|s| s.text.len()).sum());
if out.spans.len().saturating_add(arg.spans.len()) >= MAX_TRANSLATION_SPANS
|| bytes > MAX_TRANSLATION_BYTES
{
return Text::literal(template);
}
if !literal.is_empty() {
out.spans.push(Span {
text: std::mem::take(&mut literal),
@@ -772,42 +750,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
mod tests {
use super::*;
#[test]
fn appending_builds_the_same_tree_in_linear_time() {
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
// takes the siblings itself; a copy taken before an append keeps its own tree.
let mut styled = Text::literal("a").with_style(&Style {
bold: Some(true),
..Style::default()
});
styled.push(Text::literal("b"));
let before = styled.clone();
styled.push(Text::literal("c"));
assert_eq!(
styled.component(),
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}, {"text": "c"}]})
);
assert_eq!(
before.component(),
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}]})
);
let mut plain = Text::default();
plain.push(Text::literal("x"));
plain.push(Text::literal("y"));
assert_eq!(
plain.component(),
json!({"text": "", "extra": [{"text": "x"}, {"text": "y"}]})
);
// 20 000 siblings, as a server's chat may carry: 76 s before, as each append cloned the
// whole tree.
let started = std::time::Instant::now();
let mut long = Text::default();
for _ in 0..20_000 {
long.push(Text::literal("a"));
}
assert_eq!(long.spans.len(), 20_000);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
#[test]
fn click_events_serialize_and_inherit_as_one_style_field() {
for (event, json) in [
(
@@ -98,7 +98,6 @@ actions! {
Emote, "key.emote", Some(KeyCode::KeyB);
HotbarPrevious, "pad.hotbarPrevious", None;
HotbarNext, "pad.hotbarNext", None;
Radial, "pad.radial", None;
}
impl Action {
+18 -211
View File
@@ -253,17 +253,6 @@ impl Activity {
/// Horizon's pad-only actions (no vanilla key mapping).
pub const HOTBAR_PREVIOUS: &str = "pad.hotbarPrevious";
pub const HOTBAR_NEXT: &str = "pad.hotbarNext";
/// Held, opens the radial menu (`radial.rs`).
pub const RADIAL: &str = "pad.radial";
mod radial_file;
pub use radial_file::{RadialMode, RadialStick, opener_refused};
/// The `bindLayout` the settings file is written in. 1: the defaults of the binds list as they
/// are; 2: the radial menu's opener resolved against the player's own buttons.
const BIND_LAYOUT: u32 = 2;
/// The layout from which the file, not the default, says where the opener is.
const OPENER_LAYOUT: u32 = 2;
/// Bindable actions and their default buttons: vanilla key mappings (the
/// game acts as their default key) and Horizon's pad-only actions. Start is
@@ -282,7 +271,6 @@ pub const ACTIONS: &[(&str, Option<Button>)] = &[
("key.togglePerspective", Some(Button::Select)),
("key.pickItem", Some(Button::DPadLeft)),
("key.chat", Some(Button::DPadUp)),
(RADIAL, Some(Button::DPadRight)),
("key.advancements", None),
("key.playerlist", None),
("key.command", None),
@@ -315,14 +303,11 @@ pub enum Route {
HotbarStep {
next: bool,
},
Radial,
/// Held, or flipped when the pad's toggle sneak is on.
Sneak,
Screenshot,
SmoothCamera,
Fullscreen,
/// F3's debug screen, shown or hidden as F3 alone does.
DebugOverlay,
/// Does nothing by itself: what reads it asks whether the button is held
/// (`Minecraft.handleKeybinds`' toolbar keys, held with a hotbar key).
Held,
@@ -335,12 +320,10 @@ pub fn route(action: &str) -> Option<Route> {
match action {
HOTBAR_PREVIOUS => Some(Route::HotbarStep { next: false }),
HOTBAR_NEXT => Some(Route::HotbarStep { next: true }),
RADIAL => Some(Route::Radial),
"key.sneak" => Some(Route::Sneak),
"key.screenshot" => Some(Route::Screenshot),
"key.smoothCamera" => Some(Route::SmoothCamera),
"key.fullscreen" => Some(Route::Fullscreen),
"key.debug.overlay" => Some(Route::DebugOverlay),
"key.saveToolbarActivator" | "key.loadToolbarActivator" => Some(Route::Held),
_ => {
let default = crate::keybinds::KeyBinds::default_of(action);
@@ -351,16 +334,12 @@ pub fn route(action: &str) -> Option<Route> {
}
}
/// The radial menu's opener, as the binds list and the gameplay guide name it.
pub const RADIAL_CAPTION: &str = "horizon.pad.action.radial";
/// An action's name in the binds list: vanilla's translation key, or
/// Horizon's own English for the pad-only actions.
pub fn action_caption(action: &str) -> &str {
match action {
HOTBAR_PREVIOUS => "Hotbar: Previous Slot",
HOTBAR_NEXT => "Hotbar: Next Slot",
RADIAL => RADIAL_CAPTION,
other => other,
}
}
@@ -675,21 +654,6 @@ pub struct PadSettings {
pub repeat_interval: f32,
/// Action name -> button, `None` for unbound; missing means the default.
bindings: BTreeMap<&'static str, Option<Button>>,
/// The radial menu's pages.
pub radial: crate::radial::Pages,
/// Whether the radial menu's opener is held or pressed once.
pub radial_mode: RadialMode,
/// The stick that selects in it.
pub radial_stick: RadialStick,
/// Lines this game doesn't read (a newer Horizon's settings, an action it doesn't have, a
/// button it doesn't know), kept as they were so a save here loses nothing.
extra: Vec<(String, String)>,
/// The newest `bindLayout` the file has been written in: this game's, or a newer Horizon's,
/// kept so that one doesn't resolve its file again as older than it is.
bind_layout: u32,
/// The opener last used with each stick (`RadialStick::ALL` order), so stepping the Selects
/// With row and back gives each stick's own opener again.
openers: radial_file::StickOpeners,
}
impl Default for PadSettings {
fn default() -> Self {
@@ -716,12 +680,6 @@ impl Default for PadSettings {
repeat_delay: 0.35,
repeat_interval: 0.09,
bindings: BTreeMap::new(),
radial: Default::default(),
radial_mode: RadialMode::Hold,
radial_stick: RadialStick::Right,
extra: Vec::new(),
bind_layout: BIND_LAYOUT,
openers: radial_file::StickOpeners::default(),
}
}
}
@@ -748,7 +706,6 @@ impl PadSettings {
}
pub fn parse(text: &str) -> Self {
let mut s = Self::default();
let mut saved_opener = None;
for line in text.lines() {
let Some((key, value)) = line.split_once(':') else {
continue;
@@ -797,85 +754,22 @@ impl PadSettings {
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
"repeatDelay" => s.repeat_delay = bounded(s.repeat_delay, 0.1, 1.0),
"repeatInterval" => s.repeat_interval = bounded(s.repeat_interval, 0.03, 0.3),
"radialMode" => s.radial_mode = RadialMode::parse(value),
"radialStick" => s.radial_stick = RadialStick::parse(value),
// Read after the loop, from the whole text.
"bindLayout" => {}
// The radial menu's pages (`radial::Pages::parse`).
other if other.starts_with("radial_") || other == crate::radial::LAYOUT_KEY => {}
other => {
let known = other
.strip_prefix("bind_")
.filter(|action| ACTIONS.iter().any(|a| a.0 == *action));
let button = if value == "none" {
Some(None)
} else {
Button::parse(value).map(Some)
};
match (known, button) {
// The opener once the selecting stick is known.
(Some(RADIAL), Some(button)) => saved_opener = Some(button),
(Some(action), Some(button)) => s.bind(action, button),
_ => s.extra.push((other.to_owned(), value.to_owned())),
if let Some(action) = other.strip_prefix("bind_") {
let button = if value == "none" {
Some(None)
} else {
Button::parse(value).map(Some)
};
if let Some(button) = button {
s.bind(action, button);
}
}
}
}
}
// An opener on a button it can't be with this stick reads as never saved.
let refused_opener = match saved_opener {
Some(Some(b)) if opener_refused(s.radial_stick, b) => true,
Some(button) => {
s.bind(RADIAL, button);
false
}
None => false,
};
if let Some(pages) = crate::radial::Pages::parse(
text.lines()
.filter_map(|line| line.split_once(':'))
.map(|(key, value)| (key.trim(), value.trim())),
) {
s.radial = pages;
}
let lines = || text.lines().map(str::trim);
let saved = |action: &str| {
let prefix = format!("bind_{action}:");
lines().any(|line| line.starts_with(&prefix))
};
// The layout the file was written in; none in a file from before these defaults.
let layout = lines()
.find_map(|line| line.strip_prefix("bindLayout:"))
.map(|value| value.trim().parse::<u32>().unwrap_or(0));
s.bind_layout = layout.map_or(BIND_LAYOUT, |layout| layout.max(BIND_LAYOUT));
// Once, for a file from before the radial menu's opener, or one with the opener on a
// button it can no longer be: a button the player bound by hand stays theirs, and the
// opener's default doesn't take it. From then on the file says where the opener is, and
// a conflict made since stays shown in the binds list.
let before_opener = layout.is_none_or(|layout| layout < OPENER_LAYOUT);
if !text.is_empty() && (refused_opener || (before_opener && !saved(RADIAL))) {
let default_opener = ACTIONS
.iter()
.find(|(action, _)| *action == RADIAL)
.and_then(|(_, button)| *button);
let taken = s.bindings.iter().any(|(action, button)| {
*action != RADIAL && button.is_some() && *button == default_opener
});
if taken {
s.bindings.insert(RADIAL, None);
}
}
// The default too can be under the selecting thumb (D-pad Right, the left stick
// selecting): no button opens the menu until the player picks one.
if s.binding(RADIAL)
.is_some_and(|b| opener_refused(s.radial_stick, b))
{
s.bindings.insert(RADIAL, None);
}
// A file from before these defaults: its player's buttons stay where they were, and
// nothing they saved shares a button with the radial menu's opener. They can bind one
// in the binds list.
if !text.is_empty() && layout.is_none() {
s.bindings.entry(RADIAL).or_insert(None);
// A file from before these defaults: its player's buttons stay where they were.
if !text.is_empty() && !text.lines().any(|line| line.starts_with("bindLayout:")) {
for (action, button) in [
("key.drop", Button::East),
("key.togglePerspective", Button::DPadUp),
@@ -884,7 +778,10 @@ impl PadSettings {
("key.advancements", Button::DPadDown),
] {
// A button saved by hand stays, even one that's now this action's default.
if !saved(action) {
if !text
.lines()
.any(|line| line.starts_with(&format!("bind_{action}:")))
{
s.bindings.entry(action).or_insert(Some(button));
}
}
@@ -925,25 +822,7 @@ impl PadSettings {
"playInBackground:{}\n",
bool_text(self.play_in_background)
));
text.push_str(&format!(
"radialMode:{}\nradialStick:{}\n",
self.radial_mode.name(),
self.radial_stick.name()
));
text.push_str(&format!("bindLayout:{}\n", self.bind_layout));
for (key, value) in &self.extra {
// A button this game didn't know, for an action the player has bound here since.
let rebound = key
.strip_prefix("bind_")
.is_some_and(|action| self.bindings.contains_key(action));
if !rebound {
text.push_str(&format!("{key}:{value}\n"));
}
}
// The default pages aren't written: they can improve for a player who never changed them.
if self.radial != crate::radial::Pages::default() {
text.push_str(&self.radial.serialize());
}
text.push_str("bindLayout:1\n");
text
}
pub fn default_of(action: &str) -> Option<Button> {
@@ -956,15 +835,11 @@ impl PadSettings {
None => Self::default_of(action),
}
}
/// Put `action` on `button`, or unbind it. An unknown action, or a button it can't take
/// (`can_bind`), changes nothing.
/// Bind an action (`None` unbinds); unknown actions are ignored.
pub fn bind(&mut self, action: &str, button: Option<Button>) {
let Some((name, default)) = ACTIONS.iter().find(|a| a.0 == action) else {
return;
};
if button.is_some_and(|b| !self.can_bind(action, b)) {
return;
}
if button == *default {
self.bindings.remove(name);
} else {
@@ -1565,14 +1440,6 @@ pub enum Rumble {
radius: f32,
},
Totem,
/// The radial menu's stick reached a slot that would run: a tick.
RadialSlot,
/// The radial menu's page turned: a shorter tick.
RadialPage,
/// The page turn went round an end: a second tick after the first.
RadialWrap,
/// A release ran nothing after something had been selected: a soft buzz.
RadialNothing,
}
impl From<crate::input_source::Feedback> for Rumble {
fn from(event: crate::input_source::Feedback) -> Self {
@@ -1592,7 +1459,6 @@ pub struct Pulse {
pub weak: f32,
pub millis: u32,
}
use crate::radial::{RADIAL_NOTHING, RADIAL_PAGE_TICK, RADIAL_TICK};
impl Rumble {
pub fn name(self) -> &'static str {
match self {
@@ -1600,10 +1466,6 @@ impl Rumble {
Rumble::BlockBroken => "block",
Rumble::Explosion { .. } => "explosion",
Rumble::Totem => "totem",
Rumble::RadialSlot => "radial",
Rumble::RadialPage => "radial_page",
Rumble::RadialWrap => "radial_wrap",
Rumble::RadialNothing => "radial_nothing",
}
}
/// The pulse, or `None` when it is too faint to feel.
@@ -1630,9 +1492,6 @@ impl Rumble {
millis: 350,
})
}
Rumble::RadialSlot => Some(RADIAL_TICK),
Rumble::RadialPage | Rumble::RadialWrap => Some(RADIAL_PAGE_TICK),
Rumble::RadialNothing => Some(RADIAL_NOTHING),
Rumble::Totem => Some(Pulse {
strong: 0.7,
weak: 0.7,
@@ -1654,10 +1513,6 @@ impl Rumble {
};
match self {
Rumble::BlockBroken => vec![Pulse { millis: 35, ..p }],
Rumble::RadialSlot
| Rumble::RadialPage
| Rumble::RadialWrap
| Rumble::RadialNothing => vec![p],
Rumble::Hurt(_) => {
let tail = p.millis.saturating_sub(50) / 2;
vec![step(1.0, 50), step(0.55, tail), step(0.25, tail)]
@@ -2018,55 +1873,7 @@ bind_key.hotbar.2:north
let odd = PadSettings::parse(
"lookSensitivity:NaN\nbind_key.nope:south\nbind_key.jump:x9\nbindLayout:1\n",
);
assert_eq!(
odd.look_sensitivity,
PadSettings::default().look_sensitivity
);
assert_eq!(odd.binding("key.jump"), Some(Button::South));
assert_eq!(odd.actions_of(Button::South), ["key.jump"]);
}
#[test]
fn lines_this_game_doesnt_read_survive_a_save() {
// A newer Horizon's setting, an action this game doesn't have, a button it doesn't know.
let text =
"bindLayout:2\nhapticStrength:0.4\nbind_horizon:dash:south\nbind_key.jump:paddle1\n";
let s = PadSettings::parse(text);
assert_eq!(
s.binding("key.jump"),
Some(Button::South),
"the default meanwhile"
);
let saved = s.serialize();
for line in [
"hapticStrength:0.4\n",
"bind_horizon:dash:south\n",
"bind_key.jump:paddle1\n",
] {
assert!(saved.contains(line), "{line} lost from\n{saved}");
}
assert_eq!(PadSettings::parse(&saved), s);
// Bound here since, the action's own line is the one written.
let mut s = s;
s.bind("key.jump", Some(Button::West));
let saved = s.serialize();
assert!(!saved.contains("paddle1"), "{saved}");
assert_eq!(
PadSettings::parse(&saved).binding("key.jump"),
Some(Button::West)
);
}
#[test]
fn a_newer_bind_layout_is_kept() {
let newer = BIND_LAYOUT + 1;
let s = PadSettings::parse(&format!("bindLayout:{newer}\nvibration:false\n"));
assert!(s.serialize().contains(&format!("bindLayout:{newer}\n")));
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
assert!(
s.serialize()
.contains(&format!("bindLayout:{BIND_LAYOUT}\n"))
);
assert_eq!(odd, PadSettings::default());
}
#[test]
@@ -1,354 +0,0 @@
//! The radial menu's settings in the controller file (`horizon-controller.txt`): how its opener
//! holds it open, the stick that selects, which buttons the opener may be with that stick, and the
//! opener each stick had this session. `PadSettings::parse` and `serialize` read and write their
//! lines (`radialMode`, `radialStick`, `bind_pad.radial`).
use super::{Button, PadSettings, RADIAL};
/// The buttons under the right thumb, which steers the right stick.
const RIGHT_THUMB: [Button; 4] = [
Button::South,
Button::West,
Button::North,
Button::RightStick,
];
/// The buttons under the left thumb, which steers the left stick.
const LEFT_THUMB: [Button; 5] = [
Button::DPadUp,
Button::DPadDown,
Button::DPadLeft,
Button::DPadRight,
Button::LeftStick,
];
/// Whether the radial menu's opener can't be `button` while `stick` selects. One rule: the opener
/// is never under a thumb that selects (holding it and steering are two jobs for one thumb), nor
/// B, which cancels the open menu. So with the right stick the face buttons and R3 go, with the
/// left the D-pad and L3, with either both; a bumper, a trigger and View are always free, and
/// with a bumper for the opener the other bumper turns the pages.
pub fn opener_refused(stick: RadialStick, button: Button) -> bool {
button == Button::East
|| (stick != RadialStick::Left && RIGHT_THUMB.contains(&button))
|| (stick != RadialStick::Right && LEFT_THUMB.contains(&button))
}
/// How the radial menu's opener holds it open.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RadialMode {
/// Held: letting go runs what is selected.
#[default]
Hold,
/// A press opens it; A or the opener again runs what is selected, B closes it.
Toggle,
}
impl RadialMode {
pub const ALL: [Self; 2] = [Self::Hold, Self::Toggle];
pub fn name(self) -> &'static str {
match self {
Self::Hold => "hold",
Self::Toggle => "toggle",
}
}
pub fn parse(value: &str) -> Self {
Self::ALL
.into_iter()
.find(|mode| mode.name() == value)
.unwrap_or_default()
}
}
/// The stick that selects in the radial menu.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RadialStick {
#[default]
Right,
Left,
/// The last stick to lean past the select tilt.
Either,
}
impl RadialStick {
pub const ALL: [Self; 3] = [Self::Right, Self::Left, Self::Either];
pub fn name(self) -> &'static str {
match self {
Self::Right => "right",
Self::Left => "left",
Self::Either => "either",
}
}
pub fn parse(value: &str) -> Self {
Self::ALL
.into_iter()
.find(|stick| stick.name() == value)
.unwrap_or_default()
}
}
/// The opener each stick had, for this session: what the player stepped through, not a setting,
/// so it is neither saved nor compared.
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct StickOpeners([Option<Button>; 3]);
impl PartialEq for StickOpeners {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl StickOpeners {
fn slot(&mut self, stick: RadialStick) -> &mut Option<Button> {
let at = RadialStick::ALL
.iter()
.position(|s| *s == stick)
.unwrap_or(0);
&mut self.0[at]
}
}
impl PadSettings {
/// Whether `button` can be bound to `action`: anything but the radial menu's opener under
/// a thumb that selects, or on B (`opener_refused`).
pub fn can_bind(&self, action: &str, button: Button) -> bool {
action != RADIAL || !opener_refused(self.radial_stick, button)
}
/// The stick the radial menu selects with. An opener under the thumb that now selects is
/// unbound, and its row in the radial page says so. An unbound opener then takes the one this
/// stick had before in this session, or else its default where nothing else has it (D-pad
/// Right, back on the right stick).
pub fn set_radial_stick(&mut self, stick: RadialStick) {
let was = self.radial_stick;
*self.openers.slot(was) = self.binding(RADIAL);
self.radial_stick = stick;
if self
.binding(RADIAL)
.is_some_and(|b| opener_refused(stick, b))
{
self.bindings.insert(RADIAL, None);
}
if self.binding(RADIAL).is_some() {
return;
}
// The player's own, shared or not: they put it there.
if let Some(own) = self
.openers
.slot(stick)
.filter(|b| !opener_refused(stick, *b))
{
self.bind(RADIAL, Some(own));
return;
}
let default = Self::default_of(RADIAL);
let free =
default.is_some_and(|d| !opener_refused(stick, d) && self.actions_of(d).is_empty());
if free {
self.bindings.remove(RADIAL);
}
}
}
#[cfg(test)]
mod tests {
use super::super::Rumble;
use super::*;
#[test]
fn a_new_player_holds_d_pad_right_for_the_radial_menu() {
for s in [PadSettings::default(), PadSettings::parse("")] {
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
assert!(!s.conflicts(RADIAL));
}
let s = PadSettings::parse(&PadSettings::default().serialize());
assert_eq!(s, PadSettings::default());
}
#[test]
fn a_button_bound_by_hand_stays_with_it_over_the_openers_default() {
let s = PadSettings::parse("bindLayout:1\nbind_key.playerlist:right\n");
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
assert_eq!(s.binding(RADIAL), None);
assert!(!s.conflicts("key.playerlist"));
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn a_saved_opener_stays_where_it_was_saved() {
let s =
PadSettings::parse("bindLayout:1\nbind_pad.radial:right\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn a_conflict_made_since_the_migration_stays_after_a_restart() {
// Bound in the binds list onto the opener's button: both stay, and the list marks it.
let mut s = PadSettings::default();
s.bind("key.playerlist", Some(Button::DPadRight));
let text = s.serialize();
assert!(text.contains("bindLayout:2"));
let read = PadSettings::parse(&text);
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
assert!(read.conflicts(RADIAL));
assert_eq!(read, s);
// The same, hand-written with spaces around the lines.
let read = PadSettings::parse(" bindLayout:2\n bind_key.playerlist:right\n");
assert_eq!(read.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn a_layout_one_file_resolves_the_opener_once() {
let s = PadSettings::parse("bind_key.chat:up\n bindLayout:1\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), None, "the player's own button wins");
assert_eq!(s.binding("key.playerlist"), Some(Button::DPadRight));
assert_eq!(s.binding("key.chat"), Some(Button::DPadUp));
let text = s.serialize();
assert!(text.contains("bindLayout:2\n") && text.contains("bind_pad.radial:none\n"));
assert_eq!(PadSettings::parse(&text), s);
// Without a conflict the opener keeps its default.
let s = PadSettings::parse("bindLayout:1\nvibration:false\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// A line with leading spaces is still a saved opener.
let s =
PadSettings::parse("bindLayout:1\n bind_pad.radial:right\nbind_key.playerlist:right\n");
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
}
#[test]
fn the_opener_never_goes_under_the_selecting_thumb_nor_on_b() {
use Button as B;
let face = [B::South, B::West, B::North, B::RightStick];
let dpad = [
B::DPadUp,
B::DPadDown,
B::DPadLeft,
B::DPadRight,
B::LeftStick,
];
for stick in RadialStick::ALL {
let mut s = PadSettings::default();
s.set_radial_stick(stick);
for button in Button::ALL {
let refused = button == B::East
|| (stick != RadialStick::Left && face.contains(&button))
|| (stick != RadialStick::Right && dpad.contains(&button));
assert_eq!(!s.can_bind(RADIAL, button), refused, "{stick:?} {button:?}");
// Other actions still take any of them.
assert!(s.can_bind("key.chat", button));
}
// A bumper is always free, and stays one through the file.
s.bind(RADIAL, Some(B::LeftBumper));
assert_eq!(s.binding(RADIAL), Some(B::LeftBumper), "{stick:?}");
assert_eq!(PadSettings::parse(&s.serialize()), s, "{stick:?}");
}
// A file with a refused opener reads as never saved: the default, unless taken.
for button in [B::South, B::East, B::West, B::North, B::RightStick] {
let text = format!("bindLayout:2\nbind_pad.radial:{}\n", button.name());
let read = PadSettings::parse(&text);
assert_eq!(read.binding(RADIAL), Some(B::DPadRight), "{button:?}");
let taken = format!("{text}bind_key.playerlist:right\n");
assert_eq!(
PadSettings::parse(&taken).binding(RADIAL),
None,
"{button:?}"
);
}
// Read with the stick it was saved for, wherever the stick's line is.
let left = PadSettings::parse("bind_pad.radial:north\nbindLayout:2\nradialStick:left\n");
assert_eq!(left.binding(RADIAL), Some(B::North));
}
#[test]
fn a_stick_that_takes_the_openers_thumb_unbinds_it_and_the_right_gives_it_back() {
let mut s = PadSettings::default();
s.set_radial_stick(RadialStick::Left);
assert_eq!(
s.binding(RADIAL),
None,
"D-pad Right is under the left thumb"
);
assert_eq!(PadSettings::parse(&s.serialize()).binding(RADIAL), None);
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// An opener the player picked that both sticks allow stays.
s.bind(RADIAL, Some(Button::RightBumper));
s.set_radial_stick(RadialStick::Either);
assert_eq!(s.binding(RADIAL), Some(Button::RightBumper));
}
#[test]
fn stepping_the_stick_and_back_gives_each_stick_its_own_opener() {
// Left with Y, back to the right stick: Y is under the right thumb, D-pad Right opens.
let mut s = PadSettings::default();
s.set_radial_stick(RadialStick::Left);
s.bind(RADIAL, Some(Button::North));
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadRight));
// And Left again has its Y back, shared with Inventory as the player left it.
s.set_radial_stick(RadialStick::Left);
assert_eq!(s.binding(RADIAL), Some(Button::North));
// Right with D-pad Left: the left stick refuses it, and the right gets it back.
let mut s = PadSettings::default();
s.bind(RADIAL, Some(Button::DPadLeft));
s.set_radial_stick(RadialStick::Left);
assert_eq!(s.binding(RADIAL), None);
s.set_radial_stick(RadialStick::Either);
assert_eq!(s.binding(RADIAL), None);
s.set_radial_stick(RadialStick::Right);
assert_eq!(s.binding(RADIAL), Some(Button::DPadLeft));
}
#[test]
fn a_file_from_before_the_radial_menu_binds_no_opener() {
let s = PadSettings::parse("vibration:false\nbind_key.chat:up\n");
assert_eq!(s.binding(RADIAL), None);
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
#[test]
fn the_radial_menus_ticks_are_felt_and_tell_apart() {
let tick = Rumble::RadialSlot.pulse().unwrap();
assert!((40..=50).contains(&tick.millis), "{tick:?}");
assert!((0.25..=0.35).contains(&tick.weak), "{tick:?}");
let page = Rumble::RadialPage.pulse().unwrap();
assert!(page.millis < tick.millis, "a page turn is the shorter tick");
assert_eq!(Rumble::RadialWrap.pulse(), Some(page));
let nothing = Rumble::RadialNothing.pulse().unwrap();
assert!(
nothing.strong > 0.0 && nothing.millis > tick.millis,
"{nothing:?}"
);
assert!(
nothing.strong.max(nothing.weak) < 0.3,
"a soft buzz: {nothing:?}"
);
}
#[test]
fn the_radial_menus_mode_and_stick_survive_the_settings_file() {
let s = PadSettings::default();
assert_eq!(
(s.radial_mode, s.radial_stick),
(RadialMode::Hold, RadialStick::Right)
);
for mode in RadialMode::ALL {
for stick in RadialStick::ALL {
let mut s = PadSettings {
radial_mode: mode,
..PadSettings::default()
};
s.set_radial_stick(stick);
assert_eq!(PadSettings::parse(&s.serialize()), s);
}
}
let read = PadSettings::parse("radialMode:sideways\nradialStick:up\n");
assert_eq!(
(read.radial_mode, read.radial_stick),
(RadialMode::Hold, RadialStick::Right)
);
}
#[test]
fn radial_pages_survive_the_settings_file() {
let mut s = PadSettings::default();
let pages = "radial_0_label:Mine\nradial_0_slots:key.chat,,,,,,,\n";
s.radial =
crate::radial::Pages::parse(pages.lines().filter_map(|l| l.split_once(':'))).unwrap();
let read = PadSettings::parse(&s.serialize());
assert_eq!(read.radial, s.radial);
assert_ne!(read.radial, crate::radial::Pages::default());
}
}
@@ -141,13 +141,8 @@ impl Guide {
pub fn at_detail(self, detail: GuideDetail) -> Self {
match detail {
GuideDetail::Full => self,
// Nothing else in play names the radial menu.
GuideDetail::Basic => Guide {
left: self
.left
.into_iter()
.filter(|row| row.caption == crate::gamepad::RADIAL_CAPTION)
.collect(),
left: Vec::new(),
right: self.right,
},
GuideDetail::Off => Guide {
@@ -183,8 +178,6 @@ pub fn resolve(facts: &Facts, settings: &PadSettings) -> Guide {
"key.swapOffhand",
(player && any_item).then_some("horizon.controller.guide.swap"),
),
// Nothing else in play says the menu is there; it opens for a spectator too.
row(crate::gamepad::RADIAL, Some(crate::gamepad::RADIAL_CAPTION)),
]
.into_iter()
.flatten()
@@ -573,7 +566,6 @@ mod tests {
(Button::North, "key.inventory"),
(Button::DPadDown, "horizon.controller.guide.drop"),
(Button::West, "horizon.controller.guide.swap"),
(Button::DPadRight, crate::gamepad::RADIAL_CAPTION),
]
);
}
@@ -744,12 +736,12 @@ mod tests {
gliding: true,
..Facts::default()
}),
["key.inventory", crate::gamepad::RADIAL_CAPTION]
["key.inventory"]
);
}
#[test]
fn a_spectator_only_flies_opens_spectates_and_has_its_menus() {
fn a_spectator_only_flies_opens_spectates_and_has_its_menu() {
let facts = Facts {
spectator: true,
flying: true,
@@ -773,8 +765,7 @@ mod tests {
captions(&guide.left),
[
"horizon.controller.guide.fly_up",
"horizon.controller.guide.fly_down",
crate::gamepad::RADIAL_CAPTION
"horizon.controller.guide.fly_down"
]
);
}
-1
View File
@@ -16,6 +16,5 @@ pub mod keybinds;
pub mod keymapping;
pub mod modifiers;
pub mod mouse;
pub mod radial;
pub mod snap;
pub mod window;
File diff suppressed because it is too large. Load diff
+1
View File
@@ -24,6 +24,7 @@ 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" }
@@ -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()
+19 -91
View File
@@ -6,12 +6,7 @@ use horizon_world::{
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
};
use serde_json::Value;
use std::{
collections::BTreeSet,
io,
path::Path,
sync::{Arc, Mutex},
};
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
#[derive(Clone, Copy)]
@@ -104,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,
@@ -187,23 +180,6 @@ static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
/// Every block registry [`Assets::load`] has handed out, one per distinct
/// content. Callers keep `&'static` references, so a registry is never freed;
/// loading equal data again (tests load the same root once per fixture)
/// returns the one already leaked instead of leaking another (about 11 MB for
/// vanilla).
static REGISTRIES: Mutex<Vec<&'static BlockRegistry>> = Mutex::new(Vec::new());
/// `registry` leaked, or the equal one leaked before it.
fn intern_registry(registry: BlockRegistry) -> &'static BlockRegistry {
let mut leaked = REGISTRIES.lock().unwrap_or_else(|e| e.into_inner());
if let Some(&same) = leaked.iter().find(|&&known| *known == registry) {
return same;
}
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
leaked.push(registry);
registry
}
impl Assets {
pub fn load(root: &Path) -> Result<Self> {
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
@@ -278,7 +254,7 @@ impl Assets {
// Chunks hold every state of this registry: content mods can take it
// past vanilla's 16-bit global palette.
crate::chunk::set_block_states(registry.len());
let registry = intern_registry(registry);
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
Ok(Self {
registry,
properties,
@@ -342,16 +318,6 @@ impl RenderAssets {
}
None => crate::model_library::ParsedModels::default(),
};
// Connected-texture rules find their faces by sprite layer: what they match by name keeps a
// layer of its own, decided before the models insert their textures.
let ctm_rules = if draws {
horizon_optifine::ctm::load(root)
} else {
Vec::new()
};
sprites.keep_apart(crate::connected::ConnectedTextures::matched_sprites(
&ctm_rules,
));
let mut models = if draws {
let _step = crate::spans::startup::step("core.models_bake");
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
@@ -377,41 +343,27 @@ 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,
ctm_rules,
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.
@@ -444,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,
})
}
@@ -529,29 +480,6 @@ mod tests {
));
}
#[test]
fn equal_registries_are_leaked_once() {
use horizon_world::registry::BlockRegistry;
let registry = |extra: &str| {
let facts = format!(
r#"{{"defaults":{{"minecraft:air":0,"fixture:{extra}":1}},"states":[
["air",true,false,"","minecraft:air"],
["{extra}",false,false,"","fixture:{extra}"]
]}}"#
);
BlockRegistry::from_facts_json(facts.as_bytes()).unwrap()
};
let first = super::intern_registry(registry("interned_once"));
let again = super::intern_registry(registry("interned_once"));
let other = super::intern_registry(registry("interned_other"));
assert!(std::ptr::eq(first, again), "equal content leaked twice");
assert!(!std::ptr::eq(first, other), "different content shared");
assert_eq!(
other.default_state("fixture:interned_other"),
Some(horizon_world::registry::BlockStateId(1))
);
}
#[test]
fn sprite_pixels_come_back_the_same_once_packed() {
// Two layers of 16x16 RGBA with some structure, as sprites have.
@@ -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 -10
View File
@@ -1974,11 +1974,7 @@ impl ChatScreen {
return false;
}
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
// Round instead of truncating: touchpad/PixelDelta produces fractional
// amounts and truncation drops movement in one direction (#14338).
l.offset = (l.offset as f64 - amount.clamp(-1.0, 1.0))
.round()
.clamp(0.0, max as f64) as usize;
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input. The
@@ -2090,11 +2086,6 @@ impl ChatScreen {
) {
let limit = l.list.len().min(self.layout.line_limit);
let [rx, ry, rw, rh] = l.rect;
// SuggestionsList.extractRenderState: the whole list asks for the
// pointing hand while the pointer is inside its rectangle.
if mouse.0 >= rx && mouse.0 < rx + rw && mouse.1 >= ry && mouse.1 < ry + rh {
crate::widgets::request_cursor(crate::widgets::Cursor::PointingHand);
}
let has_prev = l.offset > 0;
let has_next = l.list.len() > l.offset + limit;
let moved = l.last_mouse != mouse;
@@ -1066,32 +1066,3 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
.all(|s| s.style.color.is_none())
);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
fn the_suggestion_list_asks_for_the_pointing_hand() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/");
let rect = screen
.suggestions
.as_ref()
.expect("the root commands are a suggestion list")
.rect;
let _ = crate::widgets::take_cursor();
screen.draw(&m, (rect[0] + 2, rect[1] + 2), 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::PointingHand
);
let _ = crate::widgets::take_cursor();
screen.draw(&m, (rect[0] - 1, rect[1] + 2), 0, 0);
assert_eq!(crate::widgets::take_cursor(), crate::widgets::Cursor::Arrow);
}
+147 -798
View File
@@ -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]
+15 -8
View File
@@ -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);
+19 -303
View File
@@ -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
@@ -57,8 +57,6 @@ pub enum Item {
/// Wait for a button to bind to this action.
Bind(&'static str),
ResetBinds,
/// Opens the radial menu's pages (`radial_editor`).
Radial,
Info(Info),
}
@@ -108,15 +106,6 @@ fn choice_description(opt: Opt, pad: &PadSettings) -> Option<&'static str> {
}
gamepad::Lightbar::Off => "A DualSense keeps its own light.",
},
Opt::PadRadialMode => match pad.radial_mode {
gamepad::RadialMode::Hold => "horizon.options.radial_mode.hold.about",
gamepad::RadialMode::Toggle => "horizon.options.radial_mode.toggle.about",
},
Opt::PadRadialStick => match pad.radial_stick {
gamepad::RadialStick::Right => "horizon.options.radial_stick.right.about",
gamepad::RadialStick::Left => "horizon.options.radial_stick.left.about",
gamepad::RadialStick::Either => "horizon.options.radial_stick.either.about",
},
_ => return None,
})
}
@@ -134,11 +123,6 @@ fn info(info: Info, text: &'static str) -> Setting {
}
}
/// The radial menu's group, on General (how it opens and selects) and Bindings (its pages).
const RADIAL_GROUP: &str = "Radial Menu";
/// The row that opens the radial menu's pages.
const RADIAL_PAGES: &str = "horizon.pages.open.controller_radial";
/// Shown on a binding's row until it is focused, and in the search.
const BIND_TEXT: &str = "Press this row, then the button that should do it.";
@@ -217,13 +201,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
),
],
),
(
RADIAL_GROUP,
vec![
option(Opt::PadRadialMode, "horizon.options.radial_mode.about"),
option(Opt::PadRadialStick, "horizon.options.radial_stick.about"),
],
),
(
"Menus",
vec![
@@ -254,13 +231,6 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
(
Page::Bindings,
vec![
(
RADIAL_GROUP,
vec![Setting {
item: Item::Radial,
text: "Named pages of eight actions, held open with a button.",
}],
),
("Actions", binds),
(
"Reset",
@@ -467,7 +437,6 @@ fn row(setting: Setting, s: &Settings, context: &Context) -> Row<Item> {
Row::new(item, name, value).about(about)
}
Item::ResetBinds => Row::new(item, lang::get(RESET_ALL), Value::Press).about(about),
Item::Radial => Row::new(item, lang::get(RADIAL_PAGES), Value::Press).about(about),
Item::Info(which) => {
let (name, value) = context.read(which);
Row::new(item, lang::get(name), Value::Read(value)).about(about)
@@ -527,7 +496,6 @@ impl Screen for ControllerScreen {
s.controller.reset_bindings();
vec![Output::Event(Event::Changed)]
}
Item::Radial => vec![Output::Push(crate::options_ui::Page::ControllerRadial)],
Item::Bind(_) | Item::Info(_) => Vec::new(),
}
}
@@ -565,21 +533,15 @@ impl Screen for ControllerScreen {
fn undoes(&self) -> bool {
true
}
/// This player's controller settings, bindings included, as the screen found them. The
/// radial menu's pages stay as they are: their own screen has its own Undo.
/// This player's controller settings, bindings included, as the screen found them.
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
let radial = std::mem::take(&mut s.controller.radial);
s.controller = before.controller.clone();
s.controller.radial = radial;
Vec::new()
}
/// Undo is there exactly while the controller settings differ from what the screen opened
/// with: a binding the game made while the row waited counts, a value stepped back doesn't,
/// and nor do the radial menu's pages, which this Undo keeps.
/// with: a binding the game made while the row waited counts, a value stepped back doesn't.
fn differs(&self, s: &Settings, before: &Settings) -> Option<bool> {
let mut now = s.controller.clone();
now.radial = before.controller.radial.clone();
Some(now != before.controller)
Some(s.controller != before.controller)
}
}
@@ -831,48 +793,6 @@ mod tests {
assert_eq!(arts(&batch, row), 0);
}
/// The radial menu's pages have their own screen and Undo: editing them shows no Undo here,
/// and another change's Undo keeps them as edited.
#[test]
fn radial_pages_edited_on_their_own_screen_leave_undo_alone() {
let mut s = Settings::default();
let mut ui = ui(Page::General);
draw(&mut ui, &s, None);
s.controller.radial.remove(1);
draw(&mut ui, &s, None);
assert!(
!ui.changed(),
"Undo here keeps the pages: it has nothing to do"
);
s.controller.invert_y = !s.controller.invert_y;
draw(&mut ui, &s, None);
assert!(ui.changed());
let reached = (0..100).any(|_| {
key(&mut ui, &mut s, K::Tab);
ui.focused() == Some(Target::Undo)
});
assert!(reached);
key(&mut ui, &mut s, K::Enter);
assert_eq!(s.controller.radial.len(), 1);
assert_eq!(s.controller.invert_y, PadSettings::default().invert_y);
}
/// The Bindings page's radial row opens the radial menu's pages.
#[test]
fn the_radial_row_opens_the_radial_pages() {
let mut s = Settings::default();
let mut ui = ui(Page::Bindings);
let at = ui.point_at(&s, SIZE, Item::Radial);
let outputs = ui.click(&mut s, at, SIZE, false, false, false);
assert_eq!(
outputs,
[
CLICK,
Output::Push(crate::options_ui::Page::ControllerRadial)
]
);
}
#[test]
fn the_search_finds_rows_across_pages_and_drops_empty_pages() {
let s = Settings::default();
@@ -896,8 +816,8 @@ mod tests {
let mut s = Settings::default();
s.controller.bind(JUMP, Some(Button::North));
let mut ui = ui(Page::Bindings);
// Search, the three pages, the radial menu's row, then the first action.
for _ in 0..Page::ALL.len() + 3 {
// Search, the three pages, then the first action.
for _ in 0..Page::ALL.len() + 2 {
key(&mut ui, &mut s, K::Tab);
}
assert_eq!(ui.focused(), Some(Target::Row(Item::Bind(JUMP))));
@@ -1019,8 +939,6 @@ mod tests {
(Opt::PadNintendoButtons, 2),
(Opt::PadKeyboard, 3),
(Opt::PadLightbar, 3),
(Opt::PadRadialMode, 2),
(Opt::PadRadialStick, 3),
] {
let setting = option(opt, "the fixed sentence");
let mut seen = Vec::new();
@@ -1054,8 +972,6 @@ mod tests {
Opt::PadNintendoButtons,
Opt::PadKeyboard,
Opt::PadLightbar,
Opt::PadRadialMode,
Opt::PadRadialStick,
] {
for _ in 0..4 {
lang::get(choice_description(opt, &settings.controller).unwrap());
@@ -1074,7 +990,6 @@ mod tests {
}
ControllerScreen::default().side();
lang::get(TITLE);
lang::get(RADIAL_PAGES);
for text in [
"Press the button for this. %s cancels.",
"This button also does: %s",
@@ -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()))
}
}
@@ -12,8 +12,6 @@ use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
mod noise;
pub struct Environment {
system: System,
/// What the packs' expressions read; the frame sets it before sampling.
@@ -267,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"
@@ -561,9 +559,13 @@ impl<'a> BlockHost<'a> {
}
}
/// The noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static noise::PerlinSimplex {
noise::builtin()
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
std::sync::OnceLock::new();
NOISE.get_or_init(|| {
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
})
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
@@ -613,7 +615,7 @@ impl horizon_polytone::expr::Host for BlockHost<'_> {
(V::Object(2), "noise") => {
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(polytone_noise().get(x, y))
num(f64::from(polytone_noise().get2(x, y)))
}
_ => Err(format!("{name} is not available to block colormaps")),
}
@@ -1101,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"))),
);
@@ -1,116 +0,0 @@
//! Polytone's `r.noise(x, y)`: 1.18-1.21's `PerlinSimplexNoise` over simplex noises, which 26.3 no
//! longer has. Measured on the real mod (Polytone 26.3-6.8.6) with a pack whose grass colormap encodes
//! the noise: the built-in noise is seed 0 with octaves `{1}`, read with the noise offsets
//! (`getValue(x, y, true)`), and a `polytone/noises` definition with seed 0 or 1 and octaves `{0}`,
//! `{1}` or `{0, 1}` matches this construction too.
use horizon_core::noise::SimplexNoise;
use horizon_core::random::{LegacyRandom, RandomSource};
/// `PerlinSimplexNoise(random, octaves)`: one simplex noise per octave, the highest octave first.
pub(crate) struct PerlinSimplex {
levels: Vec<Option<SimplexNoise>>,
input_factor: f64,
value_factor: f64,
}
impl PerlinSimplex {
/// The construction of `PerlinSimplexNoise(new LegacyRandomSource(seed), octaves)`: octave 0 takes
/// the first noise of `seed`'s random; the positive octaves come from a random seeded by that
/// noise's value at its own offsets, the negative ones from `seed`'s random after it.
pub(crate) fn new(seed: i64, octaves: &[i32]) -> Self {
let mut random = LegacyRandom::new(seed);
let lowest = octaves.iter().copied().min().unwrap_or(0);
let highest = octaves.iter().copied().max().unwrap_or(0);
let count = (highest - lowest + 1) as usize;
let first = SimplexNoise::new(&mut random);
let top = highest;
let wanted = |index: i32| octaves.contains(&(top - index));
let mut levels: Vec<Option<SimplexNoise>> = (0..count).map(|_| None).collect();
for index in top + 1..count as i32 {
if index >= 0 && wanted(index) {
levels[index as usize] = Some(SimplexNoise::new(&mut random));
} else {
random.consume_count(262);
}
}
if highest > 0 {
// `(long)(getValue(xo, yo, zo) * 9.223372E18F)`: the noise at its own offsets, which
// 26.3's get3 reads at (0, 0, 0). The float constant is 2^63.
let value = first.get3_f64(0.0, 0.0, 0.0) * 9.223_372e18_f32 as f64;
let mut upper = LegacyRandom::new(value as i64);
for index in (0..top).rev() {
if (index as usize) < count && wanted(index) {
levels[index as usize] = Some(SimplexNoise::new(&mut upper));
} else {
upper.consume_count(262);
}
}
}
if (0..count as i32).contains(&top) && octaves.contains(&0) {
levels[top as usize] = Some(first);
}
Self {
levels,
input_factor: 2f64.powi(highest),
value_factor: 1.0 / (2f64.powi(count as i32) - 1.0),
}
}
/// `getValue(x, y, true)`: each octave read with its offsets, which 26.3's simplex adds itself.
pub(crate) fn get(&self, x: f64, y: f64) -> f64 {
let (mut input, mut value, mut sum) = (self.input_factor, self.value_factor, 0.0);
for level in &self.levels {
if let Some(noise) = level {
sum += noise.get2_f64(x * input, y * input) * value;
}
input /= 2.0;
value *= 2.0;
}
sum
}
}
/// The built-in `r.noise(x, y)`: seed 0, octaves `{1}`.
pub(crate) fn builtin() -> &'static PerlinSimplex {
static NOISE: std::sync::OnceLock<PerlinSimplex> = std::sync::OnceLock::new();
NOISE.get_or_init(|| PerlinSimplex::new(0, &[1]))
}
#[cfg(test)]
mod tests {
use super::*;
/// `r.noise(x/8, z/8)` read on the real mod (Polytone 26.3-6.8.6): a grass colormap whose pixel
/// encodes the value, decoded to about 0.01 (polytone-work/rnoise in the measurement notes).
#[test]
fn the_builtin_noise_reads_what_the_mod_draws() {
for (x, z, mod_value) in [
(0, 10, -0.28373),
(3, 10, -0.14683),
(10, 0, 0.31833),
(24, 14, -0.55941),
(40, 25, -0.48358),
(7, 3, -0.68202),
] {
let value = builtin().get(f64::from(x) / 8.0, f64::from(z) / 8.0);
assert!(
(value - mod_value).abs() < 0.02,
"({x}, {z}): {value} against {mod_value}"
);
}
}
/// The octaves add as `PerlinSimplexNoise`'s do: `{0, 1}` is a third of `{1}` and two thirds of
/// `{0}` (measured: 0.067 = 0.33 x -0.284 + 0.67 x 0.247 at (0, 10) / 8).
#[test]
fn two_octaves_weigh_a_third_and_two_thirds() {
let both = PerlinSimplex::new(0, &[0, 1]);
let (low, high) = (PerlinSimplex::new(0, &[0]), PerlinSimplex::new(0, &[1]));
for (x, y) in [(0.0, 1.25), (2.5, -3.0), (17.1, 4.4)] {
let mixed = high.get(x, y) / 3.0 + low.get(x, y) * 2.0 / 3.0;
assert!((both.get(x, y) - mixed).abs() < 1e-12);
}
assert!((both.get(0.0, 1.25) - 0.067).abs() < 0.02);
}
}
+23 -6
View File
@@ -2,7 +2,7 @@
//! settings, kept in `horizon-extras.txt` next to `options.txt` so that file
//! stays exactly vanilla's, and the logic behind them. Every extra is off by
//! default; see `docs/extras.md`. Horizon's own switches outside the Extras page
//! (No AI, Agent Control) are kept here too.
//! (No AI, Agent Control, Discord Rich Presence) are kept here too.
use std::{collections::VecDeque, fs, path::Path};
pub const FILE: &str = "horizon-extras.txt";
@@ -145,6 +145,8 @@ pub struct Extras {
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
@@ -231,6 +233,7 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
@@ -335,6 +338,7 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
@@ -372,7 +376,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
"packBrowser:{}\npregen:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nlodZoomDetail:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesListSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\n{}cosmeticsHideFx:{}\ncosmeticsSolo:{}\n{}:{}\n{}:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -414,6 +418,7 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
@@ -608,6 +613,8 @@ pub enum Item {
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
@@ -684,6 +691,7 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
@@ -716,6 +724,7 @@ impl Item {
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
@@ -759,6 +768,7 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
@@ -782,9 +792,10 @@ impl Item {
ChatIndicator => ChatAnimation,
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | Splitscreen | ChatAnimation
| WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine | CosmeticsOthers
| CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe | Pregen => {
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
return None;
}
})
@@ -833,6 +844,9 @@ impl Item {
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
@@ -913,6 +927,7 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
@@ -971,6 +986,7 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
@@ -1088,7 +1104,7 @@ mod tests {
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles && !d.pregen);
assert!(!d.music_page && d.music_off.is_empty());
assert!(!d.wheel_cycles_settings);
assert!(d.discord_presence && !d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -1127,6 +1143,7 @@ mod tests {
e.sort_key = "key.keyboard.r".into();
e.chat_animation = true;
e.chat_indicator = false;
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
+68 -42
View File
@@ -60,7 +60,6 @@ mod entity_outline;
mod fluid;
#[cfg(test)]
mod inter_stage_tests;
mod section_fade;
mod terrain_model;
#[cfg(test)]
pub(crate) mod test_gpu;
@@ -75,10 +74,6 @@ pub use entity_batch::{
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
mark_oit,
};
use section_fade::{
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
section_visibility,
};
pub use view::{View, ViewSet};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
@@ -370,6 +365,62 @@ fn pack_terrain_camera(camera: DVec3) -> DVec3 {
let whole = camera.floor();
whole + ((camera - whole) * 65536.0).round() / 65536.0
}
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
struct FadeInputs<'a> {
now: Instant,
fade_ms: u64,
uploaded: &'a FxHashMap<SectionKey, Instant>,
retiring: &'a crate::fast_hash::FxHashSet<SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
/// A new value for `Renderer::block_entity_revision`.
fn next_block_entity_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -1125,8 +1176,6 @@ pub struct Renderer {
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
packed_quads: bool,
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
packed_on: bool,
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
/// `HORIZON_VISBUFFER=1`).
visbuf: Option<visbuffer::Visbuffer>,
@@ -4904,8 +4953,8 @@ impl Renderer {
}
None => (solid, solid_opaque, solid_cutout, solid_mirror),
};
// The solid quads in the compact layout where every pass reads it; the first world
// frames settle it for the picked pack (`follow_packed_layout`).
// The solid quads in the compact layout, while no shader pack reads the arena
// (`follow_packed_layout`).
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
packed::pack_quads(packed_quads);
// A native mod's world warp builds its pipelines with these layouts once it is given one.
@@ -5073,7 +5122,6 @@ impl Renderer {
world_warps,
flat_on: flat.is_some(),
packed_quads,
packed_on: packed_wanted(),
cull_on: cull.is_some(),
sides_on: true,
gpu_culling: None,
@@ -6710,17 +6758,13 @@ impl Renderer {
flat.set_translucent_page_vertices(arena.page_vertices());
}
}
/// Uploads solid quads in the compact layout (`packed_quads`) unless a pack reads the
/// arena's vertices as `TerrainVertex`es: Horizon's own packs' stages, and a `shaders/`
/// pack's terrain and shadow programs outside flat draws (flat ones unpack the records,
/// `pack_atlas::PULL_FLAT`). Leaving it drops every section and far tile for the game to
/// mesh again, as `follow_translucent_layout` does.
/// Uploads solid quads in the compact layout (`packed_quads`) while no shader pack runs:
/// a pack's terrain and shadow programs, and Horizon's own packs' stages, read the
/// arena's vertices as `TerrainVertex`es. Leaving it drops every section and far tile
/// for the game to mesh again, as `follow_translucent_layout` does.
fn follow_packed_layout(&mut self) {
let pack = self.pack_post.as_ref().and_then(|p| p.1.as_ref()).is_some();
let want = self.flat.is_some()
&& (!pack || self.pack_terrain_flat)
&& self.native.is_none()
&& self.packed_on;
let want = self.flat.is_some() && !pack && self.native.is_none() && packed_wanted();
if want == self.packed_quads {
return;
}
@@ -6731,10 +6775,6 @@ impl Renderer {
let waiting = self.hold.take_all();
self.place_held(waiting);
}
log::info!(
"Horizon: compact solid terrain {}",
if want { "on" } else { "off" }
);
self.packed_quads = want;
packed::pack_quads(want);
let laid_out = self.sections.values().any(|s| s.packed)
@@ -6744,17 +6784,6 @@ impl Renderer {
self.clear_sections(self.layout);
}
}
/// Allows the compact layout of solid quads or not, for A/B checks: every section and far
/// tile is meshed again in the layout that follows. Replies whether sections now use it.
pub fn set_packed_layout(&mut self, on: bool) -> bool {
if on != self.packed_on {
self.packed_on = on;
self.follow_packed_layout();
self.sections_dropped = true;
self.clear_sections(self.layout);
}
self.packed_quads
}
/// Whether every section and far tile was dropped since the last call, to be
/// laid out again (`follow_translucent_layout`): the game meshes them again,
/// as for a new renderer.
@@ -7197,8 +7226,7 @@ impl Renderer {
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]),
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
buffers: if flat { &[] } else { &buffers },
buffers: if flat { &buffers[..1] } else { &buffers },
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
depth_write: true,
atlas: None,
@@ -7368,8 +7396,7 @@ impl Renderer {
lightmap,
gui_size,
terrain: terrain.then(|| pack_post::Geometry {
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
buffers: if flat { &[] } else { &buffers },
buffers: if flat { &buffers[..1] } else { &buffers },
table: table.as_ref(),
depth_write: true,
atlas: None,
@@ -7534,8 +7561,7 @@ impl Renderer {
);
horizon_shaders::GeometryInputs {
inputs,
// Flat terrain inputs pull their own vertex (`PULL_FLAT`, `gl_VertexIndex` → `hz_vertex_index`).
pull: String::new(),
pull: pack_atlas::PULL.to_owned(),
index: "hz_indices[@]".to_owned(),
}
});
@@ -15280,8 +15306,8 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
});
}
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
/// the brightness slider's, as Paper White, and its `MaxLuminance`, the display's or a calibration
/// profile's, as Peak Brightness), kept within the settings' ranges. The player's values win.
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -548,107 +548,27 @@ impl PackAtlas {
}
}
/// GLSL: terrain.wgsl's `section_corner` over the arena page (`hz_vertices`), by quad: a
/// vertex's words as `TerrainVertex` holds them (position, then UV, layer with light, colour),
/// from vertices below `PACKED_BASE` or a compact record (`mesher/packed.rs`) past it. Records
/// give back their quad's vertices bit for bit, so packs read either layout alike.
/// `hz_arena_corner`: a vertex's position and UV words; `hz_record_corner`: corner `k`'s of the
/// record `lo`, `hi`; `hz_record_rest`: its layer-with-light and colour words.
const CORNER_FN: &str = "void hz_arena_corner(uint hz_av, out uvec3 hz_p, out uint hz_uv) {\n \
uint hz_w = hz_av * 6u;\n \
hz_p = uvec3(hz_vertices[hz_w], hz_vertices[hz_w + 1u], hz_vertices[hz_w + 2u]);\n \
hz_uv = hz_vertices[hz_w + 3u];\n\
}\n\
uint hz_corner_offset(uint hz_rel, uint hz_side, uint hz_du, uint hz_dw) {\n \
return hz_rel == 0u ? hz_side : (hz_rel == 1u ? hz_du : hz_dw);\n\
}\n\
void hz_record_corner(uvec3 hz_lo, uvec3 hz_hi, uint hz_k, out uvec3 hz_p, out uint hz_uv) {\n \
uint hz_o = (hz_hi.z >> 12u) & 0x3ffffu;\n \
uvec3 hz_block = uvec3(hz_o & 15u, (hz_o >> 4u) & 15u, (hz_o >> 8u) & 15u);\n \
uint hz_axis = (hz_o >> 12u) & 3u;\n \
uint hz_side = (hz_o >> 14u) & 1u;\n \
uint hz_step = (((hz_o >> 15u) & 3u) + (((hz_o >> 17u) & 1u) == 1u ? 4u - hz_k : hz_k)) & 3u;\n \
uint hz_du = (hz_step == 1u || hz_step == 2u) ? 1u : 0u;\n \
uint hz_dw = hz_step >= 2u ? 1u : 0u;\n \
uvec3 hz_pos = hz_block + uvec3(hz_corner_offset((3u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
hz_corner_offset((4u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
hz_corner_offset((5u - hz_axis) % 3u, hz_side, hz_du, hz_dw));\n \
uint hz_sel = hz_lo.z >> 24u;\n \
uint hz_u = (hz_lo.z >> (6u * ((hz_sel >> (2u * hz_k)) & 1u))) & 63u;\n \
uint hz_v = (hz_lo.z >> (12u + 6u * ((hz_sel >> (2u * hz_k + 1u)) & 1u))) & 63u;\n \
hz_p = floatBitsToUint(vec3(hz_pos));\n \
hz_uv = (hz_u << 10u) | (hz_v << 26u);\n\
}\n\
uvec2 hz_record_rest(uvec3 hz_lo, uvec3 hz_hi, uint hz_k) {\n \
uint hz_l = hz_k == 0u ? hz_hi.x >> 16u : (hz_k == 1u ? hz_hi.y : (hz_k == 2u ? hz_hi.y >> 12u : hz_hi.z));\n \
hz_l &= 0xfffu;\n \
uint hz_pl = ((hz_l & 63u) << 2u) | (((hz_l >> 6u) & 63u) << 10u);\n \
uint hz_g = (hz_lo.y >> (8u * hz_k)) & 255u;\n \
uint hz_t = hz_lo.x;\n \
uint hz_code = hz_k == 1u ? (hz_hi.x >> 28u) & 7u : (hz_k == 2u ? (hz_hi.y >> 24u) & 7u\n \
: (hz_k == 3u ? (hz_hi.y >> 27u) & 7u : 0u));\n \
uint hz_alpha = hz_code == 0u ? hz_t & 0xff000000u : (hz_code * 51u) << 24u;\n \
uint hz_c = (hz_g * ((hz_t >> 16u) & 255u) / 255u) | ((hz_g * ((hz_t >> 8u) & 255u) / 255u) << 8u)\n \
| ((hz_g * (hz_t & 255u) / 255u) << 16u) | hz_alpha;\n \
return uvec2((hz_hi.x & 0xffffu) | (hz_pl << 16u), hz_c);\n\
}\n";
/// GLSL: a flat-drawn vertex's inputs, and its quad's four corners' positions (`hz_cp0`..) and
/// UV words (`hz_uv0`..), pulled from the arena page as terrain.wgsl's `vs_section_flat` pulls
/// them (`CORNER_FN`): a record is read once for all four, and only the vertex's own light and
/// colour are worked out. `hz_slot`: the slot its block is found by (`hz_block_slots`, 256 a
/// block). The colour as the vertex fetch converts a Unorm8x4 (terrain.wgsl's `unorm8x4`).
const PULL_FLAT: &str = " uint hz_qv = uint(gl_VertexIndex);\n \
uint hz_own = hz_qv & 3u;\n \
uint hz_av = hz_qv & ~3u;\n \
uint hz_slot = hz_qv;\n \
bool hz_rec = false;\n \
uvec3 hz_lo = uvec3(0u);\n \
uvec3 hz_hi = uvec3(0u);\n \
if (hz_av >= 0x10000000u) {\n \
hz_slot = (hz_av - 0x10000000u) >> 2u;\n \
uint hz_rw = hz_slot * 6u;\n \
hz_lo = uvec3(hz_vertices[hz_rw], hz_vertices[hz_rw + 1u], hz_vertices[hz_rw + 2u]);\n \
hz_hi = uvec3(hz_vertices[hz_rw + 3u], hz_vertices[hz_rw + 4u], hz_vertices[hz_rw + 5u]);\n \
hz_rec = (hz_hi.z & 0x80000000u) == 0u;\n \
hz_av = hz_slot + hz_lo.x;\n \
}\n \
uvec3 hz_cp0; uvec3 hz_cp1; uvec3 hz_cp2; uvec3 hz_cp3;\n \
uint hz_uv0; uint hz_uv1; uint hz_uv2; uint hz_uv3;\n \
uvec2 hz_rest;\n \
if (hz_rec) {\n \
hz_record_corner(hz_lo, hz_hi, 0u, hz_cp0, hz_uv0);\n \
hz_record_corner(hz_lo, hz_hi, 1u, hz_cp1, hz_uv1);\n \
hz_record_corner(hz_lo, hz_hi, 2u, hz_cp2, hz_uv2);\n \
hz_record_corner(hz_lo, hz_hi, 3u, hz_cp3, hz_uv3);\n \
hz_rest = hz_record_rest(hz_lo, hz_hi, hz_own);\n \
} else {\n \
hz_arena_corner(hz_av, hz_cp0, hz_uv0);\n \
hz_arena_corner(hz_av + 1u, hz_cp1, hz_uv1);\n \
hz_arena_corner(hz_av + 2u, hz_cp2, hz_uv2);\n \
hz_arena_corner(hz_av + 3u, hz_cp3, hz_uv3);\n \
uint hz_ow = (hz_av + hz_own) * 6u;\n \
hz_rest = uvec2(hz_vertices[hz_ow + 4u], hz_vertices[hz_ow + 5u]);\n \
}\n \
uvec3 hz_vp = hz_own == 0u ? hz_cp0 : (hz_own == 1u ? hz_cp1 : (hz_own == 2u ? hz_cp2 : hz_cp3));\n \
uvec3 hz_vr = uvec3(hz_own == 0u ? hz_uv0 : (hz_own == 1u ? hz_uv1 : (hz_own == 2u ? hz_uv2 : hz_uv3)), hz_rest);\n \
hz_v_pos = uintBitsToFloat(hz_vp);\n \
hz_v_uv = uvec2(hz_vr.x & 65535u, hz_vr.x >> 16u);\n \
hz_v_layer = hz_vr.y & 65535u;\n \
hz_v_light = uvec2((hz_vr.y >> 16u) & 255u, hz_vr.y >> 24u);\n \
hz_v_color = vec4(float(hz_vr.z & 255u), float((hz_vr.z >> 8u) & 255u), float((hz_vr.z >> 16u) & 255u),\n \
float(hz_vr.z >> 24u)) * (1.0 / 255.0);\n";
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
/// inputs, for vertex `hz_vertex_index`.
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
/// (`PULL_FLAT`'s corners: four consecutive vertex indices, in either layout).
const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
vec3 hz_p1 = uintBitsToFloat(hz_cp1);\n \
vec3 hz_p2 = uintBitsToFloat(hz_cp2);\n \
uvec3 hz_w = uvec3(hz_uv0, hz_uv1, hz_uv2);\n \
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
uint hz_w3 = hz_uv3;\n \
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
@@ -700,19 +620,11 @@ impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
// Flat draws pull their vertices from the arena page (`PULL_FLAT`), which may hold
// solid quads in the compact layout; others read them as vertex attributes.
let mut declarations = if flat {
String::from(
"vec3 hz_v_pos; uvec2 hz_v_uv; uint hz_v_layer; vec4 hz_v_color; uvec2 hz_v_light;\n",
)
} else {
String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
)
};
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
);
let origin = if flat {
declarations.push_str(
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
@@ -725,8 +637,7 @@ impl PackAtlas {
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
);
declarations.push_str(CORNER_FN);
"hz_slot_origins[hz_block_slots[hz_slot >> 8u]]"
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
"hz_v_origin"
@@ -789,7 +700,6 @@ impl PackAtlas {
);
let mut body = body;
if flat {
body.insert_str(0, PULL_FLAT);
body.push_str(FACE);
body.push_str(
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
@@ -125,7 +125,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
terrain: Some(atlas.terrain_inputs(flat)),
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
inputs: atlas.terrain_inputs(true),
pull: String::new(),
pull: super::PULL.to_owned(),
index: "@".to_owned(),
}),
..Default::default()
@@ -245,135 +245,6 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A shader pack's flat terrain inputs read a compact record (`mesher::packed`) as the four
/// vertices it holds: a whole tinted face with its own ambient occlusion per corner
/// (`separateAo`), two more, and a slab's top kept as its vertices (`packed::FULL`).
#[test]
fn compact_records_feed_a_pack_the_vertices_they_hold() {
use crate::mesher::packed;
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let mut atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 16).unwrap();
queue.submit([encoder.finish()]);
atlas.separate_ao = true;
let tint = 0x7F_B238u32;
let ao = [255u32, 153, 102, 204];
let face = |x: f32, y: f32| {
let pos = [
[x, y, 5.0],
[x, y, 6.0],
[x + 1.0, y, 6.0],
[x + 1.0, y, 5.0],
];
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
(0..4).map(move |i| {
let [r, g, b] = [16, 8, 0].map(|s| 200 * (tint >> s & 255) / 255);
TerrainVertex {
pos: pos[i],
uv: uv[i],
layer: 0x8000,
light: 0xF0 | 0x40 << 8,
color: r | g << 8 | b << 16 | ao[i] << 24,
}
})
};
let solid: Vec<_> = [(3.0, 16.0), (5.0, 16.0), (7.0, 16.0), (3.0, 8.5)]
.into_iter()
.flat_map(|(x, y)| face(x, y))
.collect();
let records = packed::layout(&solid, &[tint; 4]);
assert_eq!(
packed::packed_quads(&records, 4),
3,
"whole faces pack, the slab doesn't"
);
let page = bytemuck::cast_slice(&records);
let packed_range = packed::PACKED_BASE..packed::PACKED_BASE + 16;
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let mut failures = Vec::new();
for expression in [
"gl_Vertex",
"gl_Color",
"vec4(gl_MultiTexCoord0.xy, gl_MultiTexCoord1.xy)",
"vec4(gl_Normal, 0.0)",
"at_tangent",
"at_midBlock",
"vec4(mc_midTexCoord, mc_Entity.xy)",
] {
for shadow in [false, true] {
let flags = [true, shadow];
let wide = sample_probe(&device, &queue, &atlas, &solid, origin, flags, expression);
let compact = sample_page(
&device,
&queue,
&atlas,
page,
packed_range.clone(),
origin,
flags,
expression,
);
let bits = |v: &[[f32; 4]]| v.iter().flatten().map(|f| f.to_bits()).collect::<Vec<_>>();
if bits(&wide) != bits(&compact) {
failures.push(format!(
"{expression} shadow={shadow}: {wide:?} != {compact:?}"
));
}
if expression == "gl_Color" {
for (corner, want) in ao.iter().enumerate() {
let got = compact[corner][3];
if (got - *want as f32 / 255.0).abs() > 1e-6 {
failures.push(format!("corner {corner}'s alpha {got} != {want}/255"));
}
}
}
}
}
// A pack's frames keep the translucent quads in the section arena after the solid range: three
// records and no `FULL` quad, then a water quad, whose corners must read as its own.
let mut page: Vec<[u32; 6]> = packed::layout(&solid[..12], &[tint; 3]);
let water: Vec<_> = face(9.0, 15.0).collect();
let at = page.len() as u32;
page.extend(water.iter().map(|v| bytemuck::cast::<_, [u32; 6]>(*v)));
for expression in ["vec4(gl_Normal, 0.0)", "at_tangent", "at_midBlock"] {
let flags = [true, false];
let wide = sample_probe(&device, &queue, &atlas, &water, origin, flags, expression);
let page = bytemuck::cast_slice(&page);
let after = sample_page(
&device,
&queue,
&atlas,
page,
at..at + 4,
origin,
flags,
expression,
);
if wide != after {
failures.push(format!("water {expression}: {wide:?} != {after:?}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -400,38 +271,18 @@ fn sample_probe(
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
flags: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let page = bytemuck::cast_slice(vertices);
let drawn = 0..vertices.len() as u32;
sample_page(device, queue, atlas, page, drawn, scale, flags, expression)
}
/// `sample_probe` of the vertex indices `drawn` of an arena page holding `page`.
#[allow(clippy::too_many_arguments)]
fn sample_page(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
page: &[u8],
drawn: std::ops::Range<u32>,
scale: f32,
[flat, shadow]: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let count = drawn.len() as u32;
let pp = |code| horizon_shaders::Preprocessed {
code,
..Default::default()
};
let vertex = pp(format!(
"attribute vec2 mc_midTexCoord;\nattribute vec4 at_tangent;\n\
attribute vec4 at_midBlock;\nattribute vec4 mc_Entity;\nvarying vec4 probe;\nvoid main() {{\n\
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
probe = {expression};\n\
gl_Position = vec4((float(gl_VertexID - {}) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
drawn.start,
2. / count as f32,
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2. / vertices.len() as f32,
));
// Integer attachments retain the float bits without requiring float-render-target
// capabilities on the adapter that the test runner picks.
@@ -518,8 +369,7 @@ fn sample_page(
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
// Flat draws pull their vertices from the page (`PULL_FLAT`), as in the game.
buffers: if flat { &[] } else { &vertex_layouts[..] },
buffers: &vertex_layouts[..if flat { 1 } else { 2 }],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::PointList,
@@ -558,7 +408,7 @@ fn sample_page(
);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let data = buffer(
page,
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
@@ -594,7 +444,7 @@ fn sample_page(
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: count,
width: vertices.len() as u32,
height: 1,
depth_or_array_layers: 1,
},
@@ -607,7 +457,7 @@ fn sample_page(
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(count) * 16,
size: vertices.len() as u64 * 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -631,11 +481,11 @@ fn sample_page(
for (k, group) in groups.iter().enumerate() {
pass.set_bind_group(k as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(0, data.slice(..));
pass.set_vertex_buffer(1, origin.slice(..));
}
pass.draw(drawn, 0..1);
pass.draw(0..vertices.len() as u32, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
@@ -1137,10 +1137,6 @@ fn components(name: &str) -> usize {
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
"entityColor" => 4,
// Horizon's own vec4s, a dynamic light and half a boat's water patch box; their counts
// are ints.
n if n.starts_with("horizonDynamicLight") && n != "horizonDynamicLightCount" => 4,
n if n.starts_with("horizonWaterPatch") && n != "horizonWaterPatchCount" => 4,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
}
@@ -1212,28 +1208,6 @@ mod tests {
(&matrix, "gbufferModelView", "mat4", true),
(&matrix, "gbufferModelView", "mat3", false),
(&Value::i(&[1]), GBUFFERS_FOG_MODE, "int", true),
// Horizon's own: a dynamic light and a water patch box half are vec4s, their counts
// ints.
(
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
"horizonDynamicLight0",
"vec4",
true,
),
(
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
"horizonDynamicLight0",
"float",
false,
),
(
&Value::f(&[0.5, -1.0, 62.0, 62.5]),
"horizonWaterPatch0",
"vec4",
true,
),
(&Value::i(&[3]), "horizonDynamicLightCount", "int", true),
(&Value::i(&[1]), "horizonWaterPatchCount", "int", true),
] {
assert_eq!(value.read_as(name, ty), kept, "{ty} {name}");
}
@@ -1,60 +0,0 @@
//! How far through vanilla's chunk fade-in a section is, and the instance that carries it to `terrain.wgsl`.
use crate::fast_hash::FxHashMap;
use std::time::Instant;
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
pub(super) fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
pub(super) fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
pub(super) fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
pub(super) fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
pub(super) struct FadeInputs<'a> {
pub(super) now: Instant,
pub(super) fade_ms: u64,
pub(super) uploaded: &'a FxHashMap<super::SectionKey, Instant>,
pub(super) retiring: &'a crate::fast_hash::FxHashSet<super::SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
pub(super) const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
+5 -6
View File
@@ -30,9 +30,8 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The peak is the system's own figure (DXGI's `MaxLuminance`): the display's, or a
/// calibration profile's (Windows HDR Calibration), which Horizon can't tell apart. Auto
/// uses it, and the HDR page offers calibration.
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
pub peak_guessed: bool,
}
@@ -41,7 +40,7 @@ pub struct Reported {
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but not where its peak comes from: the page offers calibration.
/// The system says, but only guesses the peak: the player calibrates.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
@@ -1356,7 +1355,7 @@ mod tests {
}
#[test]
fn hdr_setup_offers_calibration_where_the_peaks_source_is_unknown() {
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
@@ -1371,7 +1370,7 @@ mod tests {
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
// Auto still takes the reported peak, over the 1000-nit default.
// Auto still takes the guess, over the 1000-nit default.
assert_eq!(g.peak_nits(Some(windows)), 1000);
assert_eq!(g.paper_white_nits(Some(windows)), 400);
for (peak, paper_white) in [(800, 0), (0, 240)] {
+12 -70
View File
@@ -697,7 +697,7 @@ fn description(
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
"Your display's reported peak is often too high: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
@@ -705,7 +705,7 @@ fn description(
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
// A peak of unknown source isn't said to be set up; paper white is the player's own.
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
{
@@ -1219,13 +1219,10 @@ impl Item {
}
pub fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
match self {
// Far terrain renames the row; its value stays vanilla's `options.chunks`.
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => {
Text::option_name_value(
Text::literal(lang::get("Full Detail Distance")),
Text::tr("options.chunks", &[&s.render_distance]),
)
}
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
"Full Detail Distance",
lang::tr("%s chunks", &[&s.render_distance]),
),
Item::Vanilla(o) => o.label(s),
Item::Horizon(h) => h.label(s, out),
Item::Extra(e) => Text::option_name_value(
@@ -1321,11 +1318,7 @@ impl Item {
/// Whether a setting is on the screen now: Upscaling only while TAA is, which does it.
fn shown(item: Item, s: &Settings) -> bool {
match item {
Item::Horizon(H::Upscaling) => s.horizon.taa,
Item::Extra(crate::extras::Item::Fullbright) => !s.fullbright_held,
_ => true,
}
item != Item::Horizon(H::Upscaling) || s.horizon.taa
}
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
@@ -1437,7 +1430,7 @@ impl Screen for GraphicsScreen {
g::gpu_culling_offer(),
g::reduced_far_detail_suggested(),
);
shell::stamp_of(&(read(s), s.fullbright_held, offers))
shell::stamp_of(&(read(s), offers))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<Page, Item>> {
let sheet = |(page, groups): (Page, Vec<(&'static str, Vec<Setting>)>)| Sheet {
@@ -1997,31 +1990,6 @@ mod tests {
assert_eq!(key(&mut ui, &mut s, K::Escape), [Output::Done]);
}
/// On another's server Fullbright's row is gone and the screen draws again; its choice is
/// kept for the next world that allows it (#3823).
#[test]
fn fullbright_row_is_hidden_while_held_and_keeps_its_choice() {
let mut s = Settings::default();
s.extras.fullbright = true;
let screen = GraphicsScreen::default();
let listed = |s: &Settings, item| {
let sheets = screen.sheets(s);
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
groups.flat_map(|group| group.rows).any(|r| r.id == item)
};
let fullbright = Item::Extra(crate::extras::Item::Fullbright);
let lights = Item::Extra(crate::extras::Item::DynamicLights);
assert!(listed(&s, fullbright));
let free = screen.stamp(&s);
s.fullbright_held = true;
assert!(!listed(&s, fullbright));
assert!(listed(&s, lights));
assert_ne!(screen.stamp(&s), free);
assert!(s.extras.fullbright);
s.fullbright_held = false;
assert!(listed(&s, fullbright));
}
/// The strip says what a row does, what its chosen value does where that differs, and why a
/// greyed row can't be used; the search finds a row by what it does.
#[test]
@@ -2142,36 +2110,10 @@ mod tests {
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
s.horizon.lod_distance = 12;
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
}
/// With far terrain on, Render Distance reads "Full Detail Distance" but keeps vanilla's
/// `options.chunks` value: it was Horizon's "%s chunks", so "12 Chunks" became "12 chunks"
/// (#14880).
#[test]
fn full_detail_distance_keeps_the_vanilla_chunk_count() {
struct Vanilla;
impl lang::ResourceSource for Vanilla {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
serde_json::to_vec(&serde_json::json!({
"options.renderDistance": "Render Distance",
"options.chunks": "%s Chunks",
"options.generic_value": "%s: %s"
}))
.unwrap()
})
}
}
let mut s = Settings::default();
let near = s.render_distance;
let render = Item::Vanilla(Opt::RenderDistance);
let ((off, on), _) = lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
let off = render.label(&s, &NONE).plain();
s.horizon.lod_distance = 256;
(off, render.label(&s, &NONE).plain())
});
assert_eq!(off, format!("Render Distance: {near} Chunks"));
assert_eq!(on, format!("Full Detail Distance: {near} Chunks"));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s, &NONE).plain(),
format!("Full Detail Distance: {near} chunks")
);
}
#[test]
@@ -164,7 +164,7 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak may be the display's or a calibration profile's.
/// (Wayland), and Windows', whose peak is the display's own guess.
#[test]
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
@@ -200,7 +200,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
[
(
calibrate.into(),
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
"Your display's reported peak is often too high: calibrate to set it up."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
@@ -219,7 +219,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto has no known source on Windows: only Paper White is said to be set up there.
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
@@ -348,30 +348,3 @@ fn undo_never_restores_a_held_hdr_start() {
screen.undo(&mut s, &opened);
assert_eq!(s.horizon, opened.horizon);
}
/// The Calibrate row's descriptions fit the strip's two lines at vanilla's 427-wide GUI (a
/// 1080p window at scale 4 is wider): a longer one would end in "…".
#[test]
fn the_calibrate_descriptions_fit_the_strip() {
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
let mut guessed = offering(true, Some(true));
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let mut changed = Settings::default();
changed.horizon.hdr_peak = 800;
let item = Item::Horizon(H::HdrCalibrate);
let width = Columns::of(427.0, 240.0).dock[2] - 24.0;
for (s, out) in [
(Settings::default(), NONE),
(Settings::default(), guessed),
(Settings::default(), offering(true, Some(true))),
(changed, guessed),
] {
let text = description(item, &s, &out, "-");
assert_ne!(text, "-");
let lines = atlas.font.split(&Text::literal(text.to_owned()), width);
assert!(lines.len() <= 2, "{text}");
}
}
+17 -21
View File
@@ -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),
@@ -1,7 +1,6 @@
//! Room: what of a view Horizon's own HUD overlays may use. Vanilla's HUD elements are
//! keep-outs; an overlay asks the room where it fits instead of assuming a fixed spot, so it
//! doesn't cover them. The keep-outs: the boss bars at the top centre, and the status rows above
//! the hotbar at the bottom centre.
//! doesn't cover them. Today the only keep-out is the boss bars at the top centre.
//!
//! The room is built once a frame from what the HUD shows (`Game::hud_room` in gameplay). With
//! nothing showing it is empty, and every overlay stays exactly where it always was.
@@ -35,16 +34,6 @@ pub const GAP: f32 = 2.0;
pub enum Keep {
/// The boss bars and their names at the top centre (`hud_overlays::boss_bars_bottom`).
BossBars,
/// The survival rows above the hotbar at the bottom centre: health with its absorption rows,
/// armour, food or the mount's hearts, and air; from the highest row's top to the view's
/// bottom.
StatusRows,
}
impl Keep {
/// It sits at the top of the view, not the bottom.
fn at_top(self) -> bool {
matches!(self, Self::BossBars)
}
}
/// A view and the keep-outs its HUD shows this frame.
@@ -101,20 +90,10 @@ impl Room {
/// bars' bottom, or 0 with none.
pub fn top(&self) -> f32 {
self.keeps()
.filter(|(keep, _)| keep.at_top())
.map(|(_, [_, y, _, h])| y + h)
.map(|(Keep::BossBars, [_, y, _, h])| y + h)
.fold(0.0, f32::max)
}
/// How far, in GUI px from the view's top, the keep-outs at the bottom reach up: the status
/// rows' top, or the view's height with none.
pub fn bottom(&self) -> f32 {
self.keeps()
.filter(|(keep, _)| !keep.at_top())
.map(|(_, [_, y, _, _])| y)
.fold(self.height, f32::min)
}
/// Where a centred overlay that wants its top at `wanted` (GUI px) goes: at `wanted` unless
/// a keep-out reaches past it (`top() > wanted`, a real overlap; touching isn't one), else
/// `GAP` below that keep-out.
@@ -155,15 +134,4 @@ mod tests {
assert_eq!(bars(36.0).below_top(18.0), 38.0);
assert_eq!(bars(36.0).usable(), [0.0, 38.0, 500.0, 262.0]);
}
#[test]
fn the_status_rows_are_kept_at_the_bottom_and_leave_the_top_alone() {
let room = Room::new(427.0, 240.0);
assert_eq!(room.bottom(), 240.0);
// Hearts and armour from y 191 down.
let rows = room.with(Keep::StatusRows, [122.0, 191.0, 182.0, 49.0]);
assert_eq!(rows.bottom(), 191.0);
assert_eq!(rows.top(), 0.0);
assert_eq!(rows.below_top(18.0), 18.0);
}
}
@@ -131,23 +131,6 @@ pub enum GuiDraw {
pub const WHITE: u32 = 0xFFFF_FFFF;
/// The highest top, in GUI px, of the draws that sit on the view (`None` for none): where a row
/// of the HUD begins.
pub fn draws_top(draws: &[GuiDraw]) -> Option<i32> {
draws
.iter()
.filter_map(|d| match *d {
GuiDraw::Sprite { y, .. }
| GuiDraw::AtlasSprite { y, .. }
| GuiDraw::Blit { y, .. }
| GuiDraw::Text { y, .. }
| GuiDraw::Item { y, .. } => Some(y),
GuiDraw::Fill { y0, .. } => Some(y0),
GuiDraw::Push(_) | GuiDraw::Pop => None,
})
.min()
}
fn sprite(sprite: &'static str, x: i32, y: i32, w: i32, h: i32) -> GuiDraw {
GuiDraw::Sprite {
sprite: Cow::Borrowed(sprite),
@@ -583,9 +583,6 @@ pub const BOSS_BAR_STEP: i32 = 10 + 9;
pub const BOSS_BAR_HEIGHT: i32 = 5;
/// A boss bar's sprite width, in GUI px; it is centred on the view.
pub const BOSS_BAR_WIDTH: i32 = 182;
/// Half the hotbar's width, in GUI px (`Hud`'s `screenWidth / 2 - 91`): the status rows above it
/// span the same.
pub const HOTBAR_HALF_WIDTH: i32 = 91;
/// How many of `n` boss bars `BossHealthOverlay.extractRenderState` draws on a view `h` GUI px
/// tall: it stops once the next bar's top reaches a third of the height (integer division,
+1 -5
View File
@@ -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;
@@ -110,8 +109,6 @@ pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod radial_editor;
pub mod radial_overlay;
pub mod random_source;
pub mod screen_draw;
pub mod screen_effects;
@@ -172,7 +169,6 @@ pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
pub use horizon_client_input::keymapping;
pub use horizon_client_input::mouse;
pub use horizon_client_input::radial;
pub use horizon_client_input::window;
pub use horizon_command as command;
pub use horizon_items::blocks;
@@ -58,9 +58,6 @@ pub struct OverlayColours {
/// The main action, and its text.
pub accent: u32,
pub on_accent: u32,
/// A lit choice and its bright edge (the controller dial's sector).
pub lit: u32,
pub lit_edge: u32,
}
/// `ui_theme`'s type scale and shapes, in dp.
@@ -112,8 +109,6 @@ pub const NEUTRAL: MenuStyle = MenuStyle {
rule: 0x303030,
accent: 0xffffff,
on_accent: 0x000000,
lit: 0xffffff,
lit_edge: 0xffffff,
},
sizes: Sizes {
caption: 12.0,
+46 -45
View File
@@ -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,
)
};
@@ -13,9 +13,8 @@
//! - word 1: each corner's grey level before the tint;
//! - word 2: the UV rectangle in 1/32 of the sprite (u0, u1, v0, v1, 6 bits each) and, per
//! corner, which edge of it its u and v take;
//! - word 3: the layer, corner 0's light (sky and block light over 4, 6 bits each), and
//! corner 1's alpha code (`ALPHA_STEP` times it; 0: word 0's alpha);
//! - word 4: corners 1 and 2's light, and corners 2 and 3's alpha codes;
//! - word 3: the layer, and corner 0's light (sky and block light over 4, 6 bits each);
//! - word 4: corners 1 and 2's light;
//! - word 5: corner 3's light, the face (block x, y, z, the axis it faces along, which side
//! of the block, and where and which way its corners go round), and `FULL` clear.
use super::TerrainVertex;
@@ -28,8 +27,6 @@ pub const WHITE: u32 = 0xFF_FFFF;
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from the record
/// to its first vertex.
pub const FULL: u32 = 1 << 31;
/// The step of a corner's own alpha (words 3 and 4's codes): 255 / 5.
pub const ALPHA_STEP: u32 = 51;
/// The draws' base vertex of a compact mesh: `PACKED_BASE + 4 * slot + corner` is a corner of
/// the quad whose record is at `slot`; below it, vertex indices name vertices.
pub const PACKED_BASE: u32 = 1 << 28;
@@ -136,24 +133,12 @@ pub fn pack_quad(quad: &[TerrainVertex; 4], tint: u32) -> Option<[u32; 6]> {
for (i, v) in quad.iter().enumerate() {
grays |= gray_of(v.color, tint)? << (8 * i);
}
// Corners 1 to 3's alpha (a shader pack's `separateAo`: each corner's ambient occlusion,
// multiples of 51 on a whole face): `ALPHA_STEP` times the code, 0 for corner 0's.
let mut codes = [0u32; 3];
for (code, v) in codes.iter_mut().zip(&quad[1..]) {
let a = v.color >> 24;
if v.color & 0xFF00_0000 != alpha {
if a == 0 || a % ALPHA_STEP != 0 {
return None;
}
*code = a / ALPHA_STEP;
}
}
let words = [
tint | alpha,
grays,
rect | select << 24,
u32::from(quad[0].layer) | light[0] << 16 | codes[0] << 28,
light[1] | light[2] << 12 | codes[1] << 24 | codes[2] << 27,
u32::from(quad[0].layer) | light[0] << 16,
light[1] | light[2] << 12,
light[3] | orient << 12,
];
let exact = (0..4).all(|c| {
@@ -205,18 +190,12 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
let light = ((light & 63) << 2 | (light >> 6 & 63) << 10) as u16;
let g = w1 >> (8 * i) & 255;
let [r, gr, b] = channels(w0).map(|t| g * t / 255);
let code = [0, w3 >> 28 & 7, w4 >> 24 & 7, w4 >> 27 & 7][corner];
let alpha = if code == 0 {
w0 & 0xFF00_0000
} else {
(code * ALPHA_STEP) << 24
};
TerrainVertex {
pos: pos.map(|c| c as f32),
uv,
layer: (w3 & 0xFFFF) as u16,
light,
color: r | gr << 8 | b << 16 | alpha,
color: r | gr << 8 | b << 16 | (w0 & 0xFF00_0000),
}
}
@@ -268,10 +247,11 @@ pub fn place_into(
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
// Whole quads of slots: what follows (a quad's own vertices, the translucent ones after
// the solid range) then starts a quad, as a shader pack's vertex stage reads its quad's
// corners from the vertex index (`pack_atlas::PULL_FLAT`).
let pad = n.next_multiple_of(4) - n;
let pad = if full > 0 {
n.next_multiple_of(4) - n
} else {
0
};
if n + pad + 4 * full >= 4 * n {
return;
}
@@ -354,22 +334,6 @@ mod tests {
}
}
#[test]
fn each_corners_ambient_occlusion_alpha_packs_on_its_steps() {
// A shader pack's `separateAo`: the alpha byte is each corner's occlusion.
let mut quad = top([0, 1, 2, 3]);
for (v, a) in quad.iter_mut().zip([153u32, 255, 51, 204]) {
v.color = v.color & 0x00FF_FFFF | a << 24;
}
let words = pack_quad(&quad, TINT).expect("occlusion on the 51 steps packs");
for (corner, v) in quad.iter().enumerate() {
assert!(same(&packed_vertex(&words, corner), v), "corner {corner}");
}
// A partial face's blended occlusion falls between the steps.
quad[2].color = quad[2].color & 0x00FF_FFFF | 140 << 24;
assert!(pack_quad(&quad, TINT).is_none());
}
#[test]
fn quads_off_the_grid_or_the_steps_keep_their_vertices() {
// Half a block up: a slab's top.
@@ -411,13 +375,4 @@ mod tests {
// Nothing smaller to gain: no layout at all.
assert!(layout(&odd, &[TINT]).is_empty());
}
#[test]
fn the_records_end_on_a_whole_quad_of_slots() {
// Five whole faces, every one a record: the translucent vertices after them start a quad.
let solid: Vec<TerrainVertex> = (0..5).flat_map(|_| top([0, 1, 2, 3])).collect();
let out = layout(&solid, &[TINT; 5]);
assert_eq!(packed_quads(&out, 5), 5);
assert_eq!(out.len(), 8);
}
}
@@ -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 {
+82 -38
View File
@@ -18,8 +18,6 @@ use crate::{
ui,
};
mod shell_pages;
fn tr(key: &str) -> Text {
Text::translate(key, &[])
}
@@ -324,8 +322,6 @@ impl Opt {
Opt::PadNintendoButtons => "horizon.options.nintendo_buttons",
Opt::PadKeyboard => "horizon.options.on_screen_keyboard",
Opt::PadGameplayGuide => "horizon.options.gameplay_guide",
Opt::PadRadialMode => "horizon.options.radial_mode",
Opt::PadRadialStick => "horizon.options.radial_stick",
Opt::ModelPart(i) => [
"options.modelPart.cape",
"options.modelPart.jacket",
@@ -884,7 +880,6 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_text(s),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
@@ -1206,7 +1201,6 @@ impl Opt {
.unwrap_or(0) as u8;
s.controller.keyboard = all[usize::from(next(i, 3))];
}
Opt::PadRadialMode | Opt::PadRadialStick => self.radial_step(s, back),
_ => {}
}
if self.affects_preset() {
@@ -1358,11 +1352,6 @@ pub enum Page {
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
/// The radial menu's pages (`radial_editor`), on the settings shell, and a page's name.
ControllerRadial,
ControllerRadialName,
/// The list of every action for one of its slots.
ControllerRadialPick,
Extras,
/// PvP & HUD (`pvp`): the extras a PvP player sets, on their own page.
Pvp,
@@ -1512,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,
@@ -1694,16 +1683,12 @@ pub struct OptionsUi {
controller: shell::Shell<crate::controller_ui::ControllerScreen>,
/// Horizon Extras, on the settings shell.
extras: shell::Shell<crate::extras_ui::ExtrasScreen>,
/// The radial menu's pages, on the settings shell.
radial: shell::Shell<crate::radial_editor::RadialScreen>,
/// The list of actions for one of the radial menu's slots.
radial_pick: shell::Shell<crate::radial_editor::PickScreen>,
/// Shaders: the shader packs and the picked one's options, on the settings shell.
shader: shell::Shell<crate::shader_ui::ShaderScreen>,
/// 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>,
@@ -1782,8 +1767,6 @@ impl Default for OptionsUi {
graphics: Default::default(),
controller: Default::default(),
extras: Default::default(),
radial: Default::default(),
radial_pick: Default::default(),
shader: Default::default(),
pack_options: Default::default(),
pack_configs: Default::default(),
@@ -2129,7 +2112,6 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::ControllerRadialName => self.radial_name_typing(),
_ => match self.shell() {
Some(shell) => shell.typing(),
None => self.settings_search_active(),
@@ -2262,16 +2244,95 @@ impl OptionsUi {
self.studio.refresh();
}
}
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
self.graphics.screen.output = self.output;
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
/// A controller's or the keyboard's focus landed on `rect`: a row of a shell screen's list
/// that is partly hidden scrolls all into view. How far it moved down, when it moved.
pub fn reveal(&mut self, s: &Settings, size: (f32, f32), rect: [f32; 4]) -> Option<f32> {
self.shell_mut()?.reveal(s, size, rect)
}
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
fn shell_events(&mut self, s: &mut Settings, outputs: Vec<shell::Output>) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration =
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
}
self.push(page);
}
shell::Output::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
_ => None,
}
}
/// Where the controller screen opens for `page`, if it is one of its pages.
fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether a video settings screen is open, Horizon's or vanilla's: the fullscreen mode
/// waits for it to close, and Ctrl with the wheel steps the GUI scale.
pub fn video_open(&self) -> bool {
self.page() == Page::Video || Self::graphics_page(self.page()).is_some()
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// Where the graphics screen opens for `page`, if it is one of its pages.
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
match page {
@@ -2308,9 +2369,6 @@ impl OptionsUi {
"options.videoTitle"
}
Page::Controller | Page::PadBinds => "horizon.pages.controller",
Page::ControllerRadial => "horizon.pages.controller_radial",
Page::ControllerRadialName => "horizon.radial.name_title",
Page::ControllerRadialPick => "horizon.radial.pick_title",
Page::Extras => "horizon.pages.extras",
Page::Pvp => "horizon.pages.pvp",
Page::Credits => "credits_and_attribution.screen.title",
@@ -3544,9 +3602,6 @@ impl OptionsUi {
}
let waiting = self.waiting();
let in_world = self.in_world;
if self.draw_radial_name(batch, atlas, cursor, in_world) {
return;
}
if let Some(shell) = self.shell_mut() {
shell.draw(s, batch, atlas, cursor, in_world, waiting);
// The keys lay the screen out at the size it was drawn at.
@@ -3898,9 +3953,6 @@ impl OptionsUi {
let outcome = c.click(&atlas.font, (x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if self.page() == Page::ControllerRadialName {
return self.click_radial_name(s, atlas, (x, y), size, drag || right);
}
if self.shell().is_some() {
// Waiting on a controller button: a click cancels the wait.
if !drag && self.selected_pad_action.take().is_some() {
@@ -3966,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);
}
@@ -4596,8 +4648,6 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if self.page() == Page::ControllerRadialName {
self.type_radial_name(text);
} else if let Some(shell) = self.shell_mut() {
if shell.typing() {
shell.type_text(text);
@@ -4633,9 +4683,6 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if self.page() == Page::ControllerRadialName {
return self.radial_name_shortcut(shortcut, clipboard);
}
if let Some(shell) = self.shell_mut() {
return shell.shortcut(shortcut, clipboard);
}
@@ -4739,9 +4786,6 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles && self.split_profiles.key(key, text) {
return Vec::new();
}
if self.page() == Page::ControllerRadialName {
return self.radial_name_key(s, key, text);
}
// The size it was drawn at; before the first draw, one its keys can lay out at.
let gui = if self.size.0 > 0.0 && self.size.1 > 0.0 {
@@ -1,302 +0,0 @@
//! The pages Options shows on the settings shell (`settings_shell`): which shell each page
//! opens, what a shell's outputs do here, and the radial menu's pages (`radial_editor`), whose
//! name entry is drawn here in place of a shell, and whose rows of Controller Settings step here.
use super::{Event, OptionsUi, Page, tr};
use crate::{
gamepad,
hud::{HudAtlas, HudBatch},
radial_editor::Named,
settings::{Opt, Settings},
settings_shell as shell,
text::Text,
};
impl OptionsUi {
/// The open page's screen on the settings shell. This, with `shell_mut`, is all a screen
/// needs here: drawing, clicks, the wheel, a stick's scrolling, keys and typing all go
/// through them, so no input path can miss one.
pub(super) fn shell(&self) -> Option<&dyn shell::Hosted> {
match self.page() {
Page::Extras => Some(&self.extras),
page if Self::graphics_page(page).is_some() => Some(&self.graphics),
Page::ControllerRadial => Some(&self.radial),
Page::ControllerRadialPick => Some(&self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&self.controller),
page if Self::shader_page(page).is_some() => Some(&self.shader),
_ => None,
}
}
pub(super) fn shell_mut(&mut self) -> Option<&mut dyn shell::Hosted> {
self.graphics.screen.output = self.output;
match self.page() {
Page::Extras => Some(&mut self.extras),
page if Self::graphics_page(page).is_some() => Some(&mut self.graphics),
Page::ControllerRadial => Some(&mut self.radial),
Page::ControllerRadialPick => Some(&mut self.radial_pick),
page if Self::controller_page(page).is_some() => Some(&mut self.controller),
page if Self::shader_page(page).is_some() => Some(&mut self.shader),
_ => None,
}
}
/// Where the open shell screen asked the focus to open (a radial slot's list, on the slot's
/// action), for a controller's focus to land there.
pub fn shell_opened_focus(&self) -> Option<[f32; 4]> {
self.shell()?.opened_focus()
}
/// The clicks until it is set back are a controller's Left or Right on a row: a choice of a
/// settings shell steps, rather than opens.
pub fn pad_clicks_step(&mut self, on: bool) {
if on {
if let Some(shell) = self.shell_mut() {
shell.clicks_step(true);
}
return;
}
// Every shell: the click may have changed the page.
let shells: [&mut dyn shell::Hosted; 6] = [
&mut self.extras,
&mut self.graphics,
&mut self.controller,
&mut self.radial,
&mut self.radial_pick,
&mut self.shader,
];
for shell in shells {
shell.clicks_step(false);
}
}
/// The controller's focus landed there.
pub fn clear_shell_opened_focus(&mut self) {
if let Some(shell) = self.shell_mut() {
shell.clear_opened_focus();
}
}
/// The window's size, for the graphics and controller screens' rows that step an option
/// (`Opt::cycle`, which a fullscreen resolution needs): before each input that can press one.
pub(super) fn shell_framebuffers(&mut self) {
self.graphics.screen.framebuffer = self.framebuffer;
self.controller.screen.framebuffer = self.framebuffer;
}
/// Who and what Controller Settings is about: the game sets it before each draw.
pub fn set_controller_context(&mut self, context: crate::controller_ui::Context) {
self.controller.screen.context = context;
}
/// The binding the screen waits on: a controller button's, else a key's.
pub(super) fn waiting(&self) -> Option<shell::Capture> {
let pad = self.selected_pad_action.map(shell::Capture::Pad);
pad.or(self.selected_key.map(shell::Capture::Key))
}
/// What a shell screen's outputs do to these options.
pub(super) fn shell_events(
&mut self,
s: &mut Settings,
outputs: Vec<shell::Output>,
) -> Vec<Event> {
let mut events = Vec::new();
for output in outputs {
match output {
shell::Output::Event(event) => events.push(event),
shell::Output::Bind(shell::Capture::Key(name)) => self.selected_key = Some(name),
shell::Output::Bind(shell::Capture::Pad(action)) => {
self.selected_pad_action = Some(action)
}
shell::Output::Push(page) => {
if page == Page::HdrCalibration {
self.calibration =
Some(crate::hdr_calibration::Calibration::new(s, &self.output));
}
self.push(page);
// A slot's list, for the slot, opening on what it holds.
if page == Page::ControllerRadialPick
&& let Some(at) = self.radial.screen.picking.take()
{
let row = self.radial_pick.screen.open_on(at, &s.controller.radial);
self.radial_pick.focus_soon(row);
}
}
shell::Output::Done => {
// Back from a slot's list, the focus is on the slot.
let slot = (self.page() == Page::ControllerRadialPick)
.then(|| self.radial_pick.screen.at());
events.extend(self.back(s));
if let Some((page, slot)) = slot {
self.radial
.focus_soon(crate::radial_editor::RadialRow::Slot(page, slot));
}
}
}
}
// A pick on a slot's list is a change of the radial page's, which its Undo takes back.
if self.radial_pick.screen.take_picked() {
self.radial.changed_elsewhere();
}
// A radial page added or deleted: the one to show now.
if let Some(page) = self.radial.screen.take_shown() {
self.radial.show(page);
}
events
}
/// What a press on a radial page's name does here.
pub(super) fn name_events(
&mut self,
named: crate::radial_editor::Named,
s: &mut Settings,
) -> Vec<Event> {
use crate::radial_editor::Named;
let mut events = vec![Event::ButtonClick];
match named {
Named::Nothing => return Vec::new(),
Named::Done => {
let entry = self.radial.screen.naming.take();
if entry.is_some_and(|entry| entry.apply(&mut s.controller.radial)) {
self.radial.changed_elsewhere();
}
events.push(Event::Changed);
events.extend(self.back(s));
}
Named::Back => events.extend(self.back(s)),
}
events
}
/// Where the shader screen opens for `page`, if it is one of its pages.
pub(super) fn shader_page(page: Page) -> Option<crate::shader_ui::Tab> {
match page {
Page::ShaderPacks => Some(crate::shader_ui::Tab::Packs),
Page::ShaderOptions => Some(crate::shader_ui::Tab::Screen(0)),
_ => None,
}
}
/// Where the controller screen opens for `page`, if it is one of its pages.
pub(super) fn controller_page(page: Page) -> Option<crate::controller_ui::Page> {
match page {
Page::Controller => Some(crate::controller_ui::Page::General),
Page::PadBinds => Some(crate::controller_ui::Page::Bindings),
_ => None,
}
}
/// Whether the controller screen is open: a controller button pressed now may bind.
pub fn controller_open(&self) -> bool {
Self::controller_page(self.page()).is_some()
}
/// The radial screens' glyphs this frame, and the name of a radial page being typed, drawn
/// in place of a shell: whether it was.
pub(super) fn draw_radial_name(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) -> bool {
self.radial.screen.glyphs = self.pad_glyphs;
if self.page() == Page::ControllerRadialName
&& let Some(entry) = &mut self.radial.screen.naming
{
entry.draw(batch, atlas, cursor, in_world);
return true;
}
false
}
/// A click on a radial page's name entry (`ignored`: a drag or a right click).
pub(super) fn click_radial_name(
&mut self,
s: &mut Settings,
atlas: &HudAtlas,
at: (f32, f32),
size: (f32, f32),
ignored: bool,
) -> Vec<Event> {
let entry = self.radial.screen.naming.as_mut();
let named = match entry.filter(|_| !ignored) {
Some(entry) => entry.click(atlas, at, size),
None => Named::Nothing,
};
self.name_events(named, s)
}
/// Whether a radial page's name is being typed.
pub(super) fn radial_name_typing(&self) -> bool {
self.radial
.screen
.naming
.as_ref()
.is_some_and(|n| n.typing())
}
/// Text typed into a radial page's name.
pub(super) fn type_radial_name(&mut self, text: &str) {
if let Some(entry) = &mut self.radial.screen.naming {
entry.type_text(text);
}
}
/// An edit shortcut in a radial page's name: whether it did something.
pub(super) fn radial_name_shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
let entry = self.radial.screen.naming.as_mut();
entry.is_some_and(|entry| entry.shortcut(shortcut, clipboard))
}
/// A key on a radial page's name entry.
pub(super) fn radial_name_key(
&mut self,
s: &mut Settings,
key: winit::keyboard::KeyCode,
text: Option<&str>,
) -> Vec<Event> {
let named = match &mut self.radial.screen.naming {
Some(entry) => entry.key(key, text),
// Nothing to name: Escape still leaves.
None if key == winit::keyboard::KeyCode::Escape => Named::Back,
None => Named::Nothing,
};
self.name_events(named, s)
}
}
impl Opt {
/// The radial menu's rows of Controller Settings: their value.
pub(super) fn radial_text(self, s: &Settings) -> Text {
match self {
Opt::PadRadialMode => tr(match s.controller.radial_mode {
gamepad::RadialMode::Hold => "options.key.hold",
gamepad::RadialMode::Toggle => "options.key.toggle",
}),
Opt::PadRadialStick => tr(match s.controller.radial_stick {
gamepad::RadialStick::Right => "horizon.options.radial_stick.right",
gamepad::RadialStick::Left => "horizon.options.radial_stick.left",
gamepad::RadialStick::Either => "horizon.options.radial_stick.either",
}),
_ => Text::empty(),
}
}
/// The radial menu's rows of Controller Settings: a step, back or on.
pub(super) fn radial_step(self, s: &mut Settings, back: bool) {
let next = |n: u8, count: u8| {
if back {
(n + count - 1) % count
} else {
(n + 1) % count
}
};
match self {
Opt::PadRadialMode => {
let all = gamepad::RadialMode::ALL;
let i = all
.iter()
.position(|m| *m == s.controller.radial_mode)
.unwrap_or(0) as u8;
s.controller.radial_mode = all[usize::from(next(i, all.len() as u8))];
}
Opt::PadRadialStick => {
let all = gamepad::RadialStick::ALL;
let i = all
.iter()
.position(|k| *k == s.controller.radial_stick)
.unwrap_or(0) as u8;
// An opener under the thumb that now selects goes unbound.
s.controller
.set_radial_stick(all[usize::from(next(i, all.len() as u8))]);
}
_ => {}
}
}
}
@@ -124,15 +124,21 @@ impl OptionsUi {
Some(open("horizon.pages.open.studio", Page::Studio)),
),
// No AI hides the AI Agent page and everything AI.
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("horizon.pages.open.agent", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
];
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("horizon.pages.open.agent", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai)
.then(|| Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)),
)
.collect();
self.added_entries(&mut rows);
rows
}
@@ -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)));
}
}
@@ -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)
}
}
@@ -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,15 +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
}
@@ -21,17 +21,87 @@ pub trait PackFormats: Send + Sync {
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);
+13 -256
View File
@@ -368,57 +368,6 @@ pub struct PackScreen {
browser_search: TextField,
/// The browser's search box has the keyboard.
browser_focus: bool,
/// Which atlas slot holds which icon, kept from one frame to the next
/// ([`PackScreen::bind_icons`]).
icon_slots: std::cell::RefCell<IconSlots>,
}
/// The atlas's pack icon slots and the icon each holds. The atlas keeps what is
/// written into a slot, so an icon is sent once, when it takes a slot, and not
/// again every frame it is drawn.
#[derive(Clone, Debug, Default)]
struct IconSlots {
/// The atlas the pixels went into ([`HudAtlas::revision`]); a new atlas holds none of them.
revision: u64,
/// Frames bound so far: an icon drawn in this one keeps its slot.
frame: u64,
/// Least recently drawn first: the icon's texture key, its slot, its pixels, and the
/// frame it was last drawn in.
held: Vec<(String, usize, Arc<[u8]>, u64)>,
}
impl IconSlots {
/// Starts a frame on `atlas`; an atlas that is not the one the slots were filled in empties them.
fn begin(&mut self, atlas: &HudAtlas) {
if self.revision != atlas.revision {
self.held.clear();
self.revision = atlas.revision;
}
self.frame += 1;
}
/// The slot for the icon drawn under `texture`, and whether its pixels have to be sent. A new
/// icon takes a free slot, else the slot of the icon drawn longest ago; none when every slot
/// holds an icon drawn in this frame, and that icon keeps `unknown_pack`.
fn slot_for(&mut self, texture: &str, icon: &Arc<[u8]>) -> Option<(usize, bool)> {
if let Some(at) = self.held.iter().position(|h| h.0 == texture) {
let mut kept = self.held.remove(at);
let same = Arc::ptr_eq(&kept.2, icon);
kept.2 = icon.clone();
kept.3 = self.frame;
let slot = kept.1;
self.held.push(kept);
return Some((slot, !same));
}
let slot = match (0..PACK_ICON_SLOTS).find(|i| !self.held.iter().any(|h| h.1 == *i)) {
Some(free) => free,
None if self.held.first().is_some_and(|h| h.3 < self.frame) => self.held.remove(0).1,
None => return None,
};
self.held
.push((texture.to_owned(), slot, icon.clone(), self.frame));
Some((slot, true))
}
}
fn tr(key: &str) -> Text {
@@ -510,7 +459,6 @@ impl PackScreen {
field
},
browser_focus: false,
icon_slots: Default::default(),
changed: false,
};
s.reposition(m);
@@ -983,13 +931,10 @@ impl PackScreen {
self.model.commit()
}
/// Points the icon blits of `draws` at the atlas's pack icon slots and
/// writes the icons into them (vanilla registers a texture per pack). An
/// icon keeps its slot from one frame to the next and is sent when it takes
/// the slot, not every frame; the slot of the icon drawn longest ago is
/// taken first. Icons past the last slot in one frame keep `unknown_pack`.
/// writes the icons into them, one slot per icon drawn (vanilla registers a
/// texture per pack). Icons past the last slot keep `unknown_pack`.
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
let mut slots = self.icon_slots.borrow_mut();
slots.begin(atlas);
let mut slots = 0..PACK_ICON_SLOTS;
for draw in draws {
let Draw::Blit { texture, .. } = draw else {
continue;
@@ -997,23 +942,20 @@ impl PackScreen {
let Some(icon) = self.icons.get(texture.as_str()) else {
continue;
};
let Some((slot, send)) = slots.slot_for(texture, icon) else {
continue;
let Some(slot) = slots.next() else {
return;
};
let sprite = format!("horizon/pack-icon{slot}");
let Some(rect) = atlas.sprites.get(&sprite) else {
slots.held.pop();
return;
};
if send {
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
}
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
*texture = sprite;
}
}
@@ -1676,12 +1618,6 @@ impl PackScreen {
let hover = hovered == Some(i)
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
Self::draw_options_button(out, bx, by, hover);
// A control for a pad's crosshair to glide onto and its D-pad to hop to,
// while the list shows it whole.
let mark = crate::widgets::targets_mark();
let size = OPTIONS_SIZE as f32;
crate::widgets::target([bx as f32, by as f32, size, size]);
crate::widgets::clip_targets(mark, l.y as f32, l.bottom() as f32);
if hover {
tooltip = Some(tr("horizon.pack.optionsInfo"));
}
@@ -1809,14 +1745,9 @@ mod icon_tests {
.unwrap();
assert_eq!(id, "file/Configurable");
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
// Hovered, it explains itself; a pad's crosshair reaches it as a control.
// Hovered, it explains itself.
let draws = screen.draw(&m, (cx, cy), 0, 0);
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
crate::widgets::end_frame(crate::widgets::generation() + 1);
let size = OPTIONS_SIZE as f32;
let button = [bx as f32, by as f32, size, size];
let targets = crate::widgets::last_targets();
assert!(targets.iter().any(|t| t.rect == button), "{targets:?}");
assert_eq!(
screen.click(&m, cx as f64, cy as f64),
Action::Options("file/Configurable".into())
@@ -2139,177 +2070,3 @@ mod footer_label_tests {
assert_eq!(lang::code(), before, "the global language was left alone");
}
}
#[cfg(test)]
mod icon_slot_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// `count` packs with an icon each, the pixels of icon `i` all `i`.
fn packs(count: usize) -> Vec<PackInfo> {
let side = PACK_ICON_SIZE as usize;
(0..count)
.map(|i| PackInfo {
id: format!("file/pack{i:03}"),
title: Text::literal(format!("pack{i:03}")),
description: Text::literal(""),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon: Some(vec![i as u8; side * side * 4].into()),
required: false,
position: Position::Top,
fixed: false,
})
.collect()
}
/// One frame as `options_ui` draws it: the calls, then the icons bound. The icons drawn
/// (their texture keys before binding), the textures after, and the patches sent.
fn frame(screen: &PackScreen, atlas: &HudAtlas) -> (Vec<String>, Vec<String>, Vec<Patch>) {
let m = Measure(&atlas.font);
let mut draws = screen.draw(&m, (0, 0), 0, 0);
let drawn: Vec<String> = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if screen.icons.contains_key(texture) => {
Some(texture.clone())
}
_ => None,
})
.collect();
let mut patches = Vec::new();
screen.bind_icons(&mut draws, atlas, &mut patches);
let bound = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if texture.contains("pack-icon") => {
Some(texture.clone())
}
_ => None,
})
.collect();
(drawn, bound, patches)
}
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
/// window `height` high.
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
PackScreen::new(
m,
tr("resourcePack.title"),
480,
height,
false,
packs(count),
&[],
)
}
/// Every frame sent every drawn icon again (a 128 by 128 RGBA copy each) into the same
/// atlas, which keeps what it is given: an icon is sent when it takes a slot, once.
#[test]
fn an_icon_is_sent_once_and_not_every_frame() {
let atlas = atlas();
let screen = screen(&Measure(&atlas.font), 5, 400);
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!((drawn.len(), bound.len(), patches.len()), (5, 5, 5));
for again in 0..3 {
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!(rebound, bound, "frame {again}");
assert_eq!(resent.len(), 0, "frame {again}");
}
}
/// More icons drawn in one frame than there are slots: the first 64 get one, the rest keep
/// `unknown_pack`, and the next frame does not move them or send anything.
#[test]
fn past_the_slots_in_one_frame_the_rest_keep_unknown_pack() {
let atlas = atlas();
let screen = screen(&Measure(&atlas.font), PACK_ICON_SLOTS + 6, 4000);
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!(
drawn.len(),
PACK_ICON_SLOTS + 6,
"the window shows every row"
);
assert_eq!(
(bound.len(), patches.len()),
(PACK_ICON_SLOTS, PACK_ICON_SLOTS)
);
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!((rebound, resent.len()), (bound, 0));
}
/// Scrolling through more packs than slots: every icon drawn gets a slot, each one is sent
/// once on the way down, and a frame that draws what the last one drew sends nothing.
#[test]
fn scrolling_sends_only_the_icons_that_come_into_view() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut screen = screen(&m, 70, 330);
let mut seen = std::collections::HashSet::new();
let mut sent = 0;
// 150 wheel steps of 18 are more than the 2520 high list.
for step in 0..150 {
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!(bound.len(), drawn.len(), "step {step}: none falls back");
let new = drawn.iter().filter(|t| seen.insert((*t).clone())).count();
assert_eq!(patches.len(), new, "step {step}");
sent += patches.len();
assert_eq!(frame(&screen, &atlas).2.len(), 0, "step {step}: same view");
screen.scroll(100.0, 200.0, -1.0);
}
assert_eq!((seen.len(), sent), (70, 70));
}
/// The slot of the icon drawn longest ago is the one taken, and the icon that left it is
/// sent again when it comes back.
#[test]
fn the_icon_drawn_longest_ago_gives_up_its_slot() {
let icons: Vec<Arc<[u8]>> = packs(PACK_ICON_SLOTS + 1)
.into_iter()
.map(|p| p.icon.unwrap())
.collect();
let mut slots = IconSlots::default();
let key = |i: usize| format!("k{i}");
slots.begin(&atlas());
for i in 0..PACK_ICON_SLOTS {
assert_eq!(slots.slot_for(&key(i), &icons[i]), Some((i, true)));
}
// The next frame draws icon 0 first, then a new one: slot 1, the oldest left, is taken.
slots.frame += 1;
assert_eq!(slots.slot_for(&key(0), &icons[0]), Some((0, false)));
assert_eq!(
slots.slot_for("new", &icons[PACK_ICON_SLOTS]),
Some((1, true))
);
// Icon 1 comes back: it is sent again, into the slot of the next oldest.
assert_eq!(slots.slot_for(&key(1), &icons[1]), Some((2, true)));
}
/// New pixels under the same key take the same slot and are sent; a new atlas holds none
/// of the icons, so all are sent again.
#[test]
fn changed_pixels_and_a_new_atlas_send_again() {
let atlas = atlas();
let mut screen = screen(&Measure(&atlas.font), 3, 400);
let (_, bound, patches) = frame(&screen, &atlas);
assert_eq!(patches.len(), 3);
let key = icon_texture("file/pack001");
screen
.icons
.insert(key, vec![200; patches[1].rgba.len()].into());
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!(rebound, bound);
assert_eq!(resent.len(), 1);
assert_eq!((resent[0].x, resent[0].y), (patches[1].x, patches[1].y));
assert_eq!(resent[0].rgba[0], 200);
let other = self::atlas();
assert_ne!(other.revision, atlas.revision);
assert_eq!(frame(&screen, &other).2.len(), 3);
}
}
+21 -64
View File
@@ -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);
+64 -18
View File
@@ -31,10 +31,8 @@ mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
mod simple;
mod spawn;
pub use native::{ParticleController, ParticleUpdate};
use simple::{PARALLEL_MIN, parallel_threads, sprite_from_age_in, tick_simple};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -1327,9 +1325,6 @@ pub struct ParticleEngine {
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
set_sprites: Vec<Vec<u32>>,
/// How many live particles it takes to tick them on several threads (`tick_all`), and how many threads.
parallel_min: usize,
parallel_threads: usize,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// Vanilla particle types a pack replaces with a custom one of the same id
@@ -1532,8 +1527,6 @@ impl ParticleEngine {
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
parallel_min: PARALLEL_MIN,
parallel_threads: parallel_threads(),
polytone: None,
polytone_overrides: HashMap::new(),
polytone_gpu: Vec::new(),
@@ -2128,7 +2121,23 @@ impl ParticleEngine {
}
/// `SingleQuadParticle.setSpriteFromAge`.
fn sprite_from_age(&self, p: &mut Particle) {
sprite_from_age_in(&self.set_sprites, p);
if p.removed {
return;
}
if let Some(set) = p.set {
// A sprite set's index can outlive the reload that rebuilt them.
let Some(sprites) = self.set_sprites.get(set as usize) else {
return;
};
let lifetime = p.lifetime.max(1);
let index = if p.frame_ticks > 0 {
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
} else {
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
.clamp(0, sprites.len() as i64 - 1)
};
p.sprite = Sprite::Index(sprites[index as usize]);
}
}
#[allow(clippy::too_many_lines)]
/// `ParticleProvider.createParticle`: provider draws use the engine's
@@ -3838,7 +3847,9 @@ impl ParticleEngine {
self.emitters = emitters;
let mut particles = std::mem::take(&mut self.particles);
self.spawned_in_tick = Some(Vec::new());
self.tick_all(&mut particles, level);
for p in &mut particles {
self.tick_particle(p, level);
}
particles.retain(|p| !p.removed);
self.particles = particles;
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
@@ -3943,15 +3954,7 @@ impl ParticleEngine {
#[allow(clippy::too_many_lines)]
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
match p.behaviour {
Behaviour::Base
| Behaviour::Mod { .. }
| Behaviour::Animated
| Behaviour::Snowflake
| Behaviour::Crit
| Behaviour::Spark { .. } => {
let simple = tick_simple(&self.set_sprites, p, level);
self.finish_simple(p, simple);
}
Behaviour::Base => p.base_tick(level),
Behaviour::Controlled { .. } => {
if let Some(mut controller) = p.controller.take() {
let update = controller.tick(p, level);
@@ -3970,6 +3973,23 @@ impl ParticleEngine {
p.remove();
}
}
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
self.sprite_from_age(p);
}
Behaviour::Animated => {
p.base_tick(level);
self.sprite_from_age(p);
}
Behaviour::Snowflake => {
p.base_tick(level);
self.sprite_from_age(p);
p.velocity[0] *= f64::from(0.95f32);
p.velocity[1] *= f64::from(0.9f32);
p.velocity[2] *= f64::from(0.95f32);
}
Behaviour::Lava => {
p.base_tick(level);
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
@@ -3980,6 +4000,11 @@ impl ParticleEngine {
);
}
}
Behaviour::Crit => {
p.base_tick(level);
p.rgba[1] *= 0.96;
p.rgba[2] *= 0.9;
}
Behaviour::Drip {
fluid,
stage,
@@ -4450,6 +4475,27 @@ impl ParticleEngine {
}
}
}
Behaviour::Spark {
fade,
trail,
twinkle,
} => {
p.base_tick(level);
self.sprite_from_age(p);
if p.age > p.lifetime / 2 {
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
if let Some(f) = fade {
for i in 0..3 {
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
}
}
}
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
child.age = child.lifetime / 2;
self.pending.push(child);
}
}
Behaviour::Spell { original_alpha } => {
p.base_tick(level);
self.sprite_from_age(p);
@@ -774,7 +774,7 @@ impl Host for Ctx<'_, '_> {
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(crate::environment::polytone_noise().get(x, y))
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
}
(id, _) if id >= BLOCKS => {
let at = *self
@@ -796,7 +796,7 @@ 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")),
}
@@ -1,161 +0,0 @@
//! The particles that tick on their own (`tick_simple`): the behaviours that read and write only
//! their own particle, the world and the sprite sets, which `ParticleEngine::tick_all` runs on several
//! threads in a busy scene.
use super::{Behaviour, Particle, ParticleEngine, ParticleLevel, Sprite, mod_curve};
/// The live particles it takes before `tick_all` uses more than one thread: a scoped thread costs
/// tens of microseconds to start and join, and a simple particle about a tenth of one.
pub(super) const PARALLEL_MIN: usize = 2048;
/// Threads for ticking particles: half the machine's workers, at most four (the chunk workers and
/// the render thread need cores too); `HORIZON_WORKER_THREADS` caps them as it does every pool.
pub(super) fn parallel_threads() -> usize {
(horizon_threads::worker_count() / 2).clamp(1, 4)
}
pub(super) fn sprite_from_age_in(set_sprites: &[Vec<u32>], p: &mut Particle) {
if p.removed {
return;
}
if let Some(set) = p.set {
// A sprite set's index can outlive the reload that rebuilt them.
let Some(sprites) = set_sprites.get(set as usize) else {
return;
};
let lifetime = p.lifetime.max(1);
let index = if p.frame_ticks > 0 {
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
} else {
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
.clamp(0, sprites.len() as i64 - 1)
};
p.sprite = Sprite::Index(sprites[index as usize]);
}
}
/// The tick of the behaviours that read and write only their own particle, the world and the engine's
/// sprite sets: no particle spawned, no sound, nothing drawn from the engine's random. Those are the
/// bulk of a busy scene (flames, smoke, poofs, dust, crits), and `ParticleEngine::tick` runs them on
/// several threads. Any other behaviour is left alone, for `ParticleEngine::tick_particle`.
pub(super) fn tick_simple(
set_sprites: &[Vec<u32>],
p: &mut Particle,
level: &ParticleLevel,
) -> Simple {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
sprite_from_age_in(set_sprites, p);
}
Behaviour::Animated => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
}
Behaviour::Snowflake => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
p.velocity[0] *= f64::from(0.95f32);
p.velocity[1] *= f64::from(0.9f32);
p.velocity[2] *= f64::from(0.95f32);
}
Behaviour::Crit => {
p.base_tick(level);
p.rgba[1] *= 0.96;
p.rgba[2] *= 0.9;
}
Behaviour::Spark { fade, trail, .. } => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
if p.age > p.lifetime / 2 {
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
if let Some(f) = fade {
for i in 0..3 {
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
}
}
}
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
return Simple::SparkTrail;
}
}
_ => return Simple::No,
}
Simple::Done
}
/// The same world with a one-chunk lookup cache of its own, for a worker thread.
///
/// `ParticleLevel::cached_chunk` is a `Cell`, which a level shared between threads may not hold:
/// each thread gets a view with an empty cache, built here on the engine thread and moved in. A
/// cache they shared could not hold "the last chunk looked up" for any one of them in any case.
fn thread_view<'a>(level: &ParticleLevel<'a>) -> ParticleLevel<'a> {
ParticleLevel {
world: level.world,
facts: level.facts,
collision: level.collision,
player: level.player,
cached_chunk: std::cell::Cell::new(None),
}
}
/// What `tick_simple` did with a particle.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Simple {
/// Not one of its behaviours: the engine ticks it.
No,
/// Ticked completely.
Done,
/// Ticked, except that a firework spark's trail wants a child. Only the engine can make one:
/// it takes the next seed, so the children come in the order the sparks have.
SparkTrail,
}
impl ParticleEngine {
/// What `tick_simple` leaves to the engine: a spark's trail child.
pub(super) fn finish_simple(&mut self, p: &Particle, simple: Simple) {
if simple != Simple::SparkTrail {
return;
}
if let Behaviour::Spark { fade, twinkle, .. } = p.behaviour {
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
child.age = child.lifetime / 2;
self.pending.push(child);
}
}
/// One tick of every live particle. The ones `tick_simple` covers are independent of each
/// other and of the engine, so with enough of them they tick on several threads; every other
/// one then ticks here, in the order it had, so the particles, the engine's random and what
/// is spawned come out as when all of them ticked one after another.
pub(super) fn tick_all(&mut self, particles: &mut [Particle], level: &ParticleLevel) {
if particles.is_empty() || particles.len() < self.parallel_min || self.parallel_threads < 2
{
for p in particles {
self.tick_particle(p, level);
}
return;
}
let sets = &self.set_sprites;
let per = particles.len().div_ceil(self.parallel_threads);
let mut handled = vec![Simple::No; particles.len()];
std::thread::scope(|scope| {
let run = |part: &mut [Particle], done: &mut [Simple], level: &ParticleLevel| {
for (p, done) in part.iter_mut().zip(done) {
*done = tick_simple(sets, p, level);
}
};
let mut parts = particles.chunks_mut(per).zip(handled.chunks_mut(per));
let own = parts.next();
for (part, done) in parts {
let view = thread_view(level);
scope.spawn(move || run(part, done, &view));
}
// This thread takes the first share, instead of waiting for the others.
if let Some((part, done)) = own {
run(part, done, level);
}
});
for (p, done) in particles.iter_mut().zip(handled) {
match done {
Simple::No => self.tick_particle(p, level),
simple => self.finish_simple(p, simple),
}
}
}
}
@@ -1,12 +1,8 @@
//! The particle stress scene's load on `ParticleEngine`, outside the game.
//!
//! - `parallel_tick_matches_serial_tick`: the engine ticking on several threads (`tick_all`) leaves
//! every particle, the engine's random and its sounds exactly as ticking them one after another.
//! - `particle_tick_cost` (`cargo test --profile fast -p horizon-client-render --lib
//! particle_tick_cost -- --ignored --nocapture`, prepared resources; `PARTICLE_BENCH_SECS` sets
//! the run, default 2): spawns what tools/stress_scenes.py's `particles` scene makes each tick
//! (flames, cosy smoke, end rods, TNT debris, rockets) over its stone floor and times the spawns
//! and the engine tick apart, once the engine holds as many particles as it will.
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
use super::*;
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
use std::sync::Arc;
@@ -29,7 +25,6 @@ impl Lcg {
}
}
/// What to spawn each tick: a type, how many, around where, how far, how fast.
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
@@ -63,29 +58,9 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
}
}
/// The scene's emitters, per tick: flames, cosy smoke, end rods, and TNT's debris (poof and smoke).
fn emitters() -> [Emitter; 5] {
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
[
(ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01),
(
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
),
(ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05),
(ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4),
(ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4),
]
}
/// Runs `f` over the stress scene's world (a stone floor at y 289 under the particles). Skipped
/// without prepared resources.
fn with_scene(f: impl FnOnce(&ParticleLevel)) {
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
@@ -113,127 +88,69 @@ fn with_scene(f: impl FnOnce(&ParticleLevel)) {
world.set(x, FLOOR_Y, z, floor).unwrap();
}
}
f(&ParticleLevel {
let level = ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
});
}
fn engine(parallel_min: usize, threads: usize) -> ParticleEngine {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
engine.parallel_min = parallel_min;
engine.parallel_threads = threads;
engine
}
/// Every live particle's fields, as text.
fn state(engine: &ParticleEngine) -> Vec<String> {
engine
.particles
.iter()
.map(|p| format!("{:?}", engine.snapshot(p)))
.collect()
}
#[test]
fn parallel_tick_matches_serial_tick() {
with_scene(|level| {
let types = emitters();
// One engine ticks every particle in turn; the other on three threads. Same seeds, same spawns.
let mut serial = engine(usize::MAX, 1);
let mut threaded = engine(0, 3);
for e in [&mut serial, &mut threaded] {
e.seed_randoms(7, 8, 9);
}
let (mut a, mut b) = (Lcg(99), Lcg(99));
for tick in 0..90 {
spawn_tick(&mut serial, &mut a, &types, 3);
spawn_tick(&mut threaded, &mut b, &types, 3);
serial.tick(level);
threaded.tick(level);
assert_eq!(serial.len(), threaded.len(), "tick {tick}");
if tick % 10 == 9 {
assert_eq!(state(&serial), state(&threaded), "tick {tick}");
}
}
assert!(serial.len() > 1000, "{} particles", serial.len());
assert_eq!(serial.sounds.len(), threaded.sounds.len());
assert_eq!(serial.next_randoms(), threaded.next_randoms());
});
}
/// Spawns and ticks `types` once the engine is full, for `secs`, and prints the cost per tick.
fn measure(
level: &ParticleLevel,
name: &str,
(types, rockets): (&[Emitter], usize),
(parallel_min, threads): (usize, usize),
secs: f64,
) {
let mut engine = engine(parallel_min, threads);
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, types, rockets);
engine.tick(level);
}
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
}
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:24} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
let cosy: Emitter = (
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
);
}
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
with_scene(|level| {
let [flame, cosy, rod, poof, smoke] = emitters();
let all = [flame, cosy, rod, poof, smoke];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
let one = (usize::MAX, 1);
eprintln!("default threads: {}", parallel_threads());
for (name, types, rockets) in [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
] {
measure(level, name, (&types, rockets), one, secs);
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
let cases: [(&str, Vec<Emitter>, usize); 7] = [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
for (name, types, rockets) in cases {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, &types, rockets);
engine.tick(&level);
}
measure(level, "whole scene, 1 thread", (&all, 3), one, secs);
for threads in [2, 3, 4, 6] {
measure(
level,
&format!("whole scene, {threads} threads"),
(&all, 3),
(PARALLEL_MIN, threads),
secs,
);
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, &types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(&level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
}
});
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
);
}
}
@@ -1,844 +0,0 @@
//! The radial menu's pages in the controller settings (Horizon's own; `radial.rs` has their
//! rules, `radial_overlay.rs` draws them in play), on the settings shell (`settings_shell`): a
//! sheet per page with its eight slots, what changes the page, and what changes them all. A
//! page's own name is typed on a small page of its own, [`NameEntry`].
use crate::{
gamepad::{self, GlyphOf, MenuJob},
graphics_style::{self as style, BUTTON_H},
hud::{HudAtlas, HudBatch},
lang,
options_ui::{Event, Page},
pad_glyph::{Glyph, Glyphs},
radial::{self, Pages, RadialAction, SLOTS, Slot},
radial_overlay::{page_label, slot_caption},
settings::Settings,
settings_shell::{
self as shell, Binding, Capture, Group, Output, Reset, Row, Screen, Sheet, Shown, Value,
},
text::Text,
ui,
};
const TITLE: &str = "horizon.pages.controller_radial";
const SLOTS_GROUP: &str = "horizon.radial.group.slots";
const PAGE_GROUP: &str = "horizon.radial.group.page";
const ALL_GROUP: &str = "horizon.radial.group.all";
/// The opener's row binds the menu's own pad action, in place.
const OPENER: &str = gamepad::RADIAL;
/// A row of a page; `usize`s are the page and the slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RadialRow {
Slot(usize, usize),
Rename(usize),
NewPage,
Delete(usize),
Reset,
Opener,
}
/// A slot's action, that it holds none, or that its action is missing.
fn slot_label(slot: &Slot) -> String {
slot_caption(slot).unwrap_or_else(|| lang::get("horizon.radial.empty"))
}
/// What the slot after `slot` (before, `back`) holds: Empty between the last action and the first.
/// A missing action steps as Empty does.
fn step(slot: &Slot, back: bool) -> Slot {
let order: Vec<_> = std::iter::once(None)
.chain(RadialAction::ALL.map(Some))
.collect();
let at = order
.iter()
.position(|entry| *entry == slot.action())
.unwrap_or(0);
let next = if back { at + order.len() - 1 } else { at + 1 };
order[next % order.len()].into()
}
fn page_of(page: usize, pages: usize) -> String {
lang::tr("horizon.radial.page_of", &[&(page + 1), &pages])
}
/// The opener's row: a binding of the menu's own pad action, in place, on its button's art. Its
/// strip says what stands in the way (no button, or one another action shares); while it waits,
/// the shell says how to bind it.
fn opener_row(s: &Settings, glyphs: Glyphs) -> Row<RadialRow> {
let pad = &s.controller;
// Through the player's glyphs, so a Nintendo pad going by its letters names its own button.
let glyph = Glyph::of(GlyphOf::Bound(OPENER), pad, glyphs);
let warning = match pad.binding(OPENER) {
None => Some(lang::get("horizon.radial.unbound")),
Some(_) => pad
.conflicts(OPENER)
.then(|| lang::get("horizon.radial.conflict")),
};
let binding = Binding {
capture: Capture::Pad(OPENER),
shown: Shown::Pad {
glyph,
cancel: Glyph::menu(MenuJob::Start, glyphs),
},
shared: Vec::new(),
// The default (D-pad Right) is refused with some sticks: no reset, only a new button.
reset: Reset::None,
};
let name = lang::get("horizon.radial.opener");
Row {
chosen: warning,
..Row::new(RadialRow::Opener, name, Value::Binding(binding))
.about(lang::get(opener_about(pad.radial_stick)))
}
}
/// What the opener's row says it can be, with the stick that selects: never under that thumb.
fn opener_about(stick: gamepad::RadialStick) -> &'static str {
match stick {
gamepad::RadialStick::Right => "horizon.radial.opener.about",
gamepad::RadialStick::Left => "horizon.radial.opener.about.left",
gamepad::RadialStick::Either => "horizon.radial.opener.about.either",
}
}
/// The page's rows: its slots, then what changes it.
fn page_groups(pages: &Pages, page: usize) -> [Group<RadialRow>; 2] {
let slots = (0..SLOTS).map(|slot| {
let name = lang::get(&format!("horizon.radial.slot.{slot}"));
let cell = pages.page(page).slot(slot);
let value = Value::Choice(slot_label(cell));
let missing = match cell {
Slot::Missing(id) => Some(lang::tr("horizon.radial.missing.about", &[id])),
_ => None,
};
Row {
chosen: missing,
..Row::new(RadialRow::Slot(page, slot), name, value)
.about(lang::get("horizon.radial.slot.about"))
}
});
let full = (pages.len() >= radial::MAX_PAGES)
.then(|| lang::tr("horizon.radial.new_page.full", &[&radial::MAX_PAGES]));
let last = (pages.len() <= 1).then(|| lang::get("horizon.radial.delete_page.last"));
let changes = vec![
Row::new(
RadialRow::Rename(page),
lang::get("horizon.radial.rename"),
Value::Press,
)
.about(lang::get("horizon.radial.rename.about")),
Row::new(
RadialRow::NewPage,
lang::get(radial::NEW_PAGE_NAME),
Value::Press,
)
.about(lang::get("horizon.radial.new_page.about"))
.blocked(full),
Row::new(
RadialRow::Delete(page),
lang::get("horizon.radial.delete_page"),
Value::Press,
)
.about(lang::get("horizon.radial.delete_page.about"))
.blocked(last),
];
[
Group {
name: SLOTS_GROUP,
rows: slots.collect(),
},
Group {
name: PAGE_GROUP,
rows: changes,
},
]
}
/// The screen's own state; the pages are the player's settings.
#[derive(Default)]
pub(crate) struct RadialScreen {
/// The buttons' names follow the controller in use: the options screen says which.
pub(crate) glyphs: Glyphs,
/// The page to show next: one just added, or where a deleted one was.
show: Option<usize>,
/// The page whose name is being typed, on its own page (`Page::ControllerRadialName`).
pub(crate) naming: Option<NameEntry>,
/// The slot (page, slot) whose list of actions opens next (`Page::ControllerRadialPick`).
pub(crate) picking: Option<(usize, usize)>,
}
impl RadialScreen {
/// The page the shell should show now, once.
pub(crate) fn take_shown(&mut self) -> Option<usize> {
self.show.take()
}
}
impl Screen for RadialScreen {
type Page = usize;
type Row = RadialRow;
fn title(&self) -> &'static str {
TITLE
}
/// The rows read the pages, the opener's button, the stick that selects and the glyphs it is
/// named with.
fn stamp(&self, s: &Settings) -> u64 {
let pad = &s.controller;
let opener = (pad.binding(OPENER), pad.conflicts(OPENER));
shell::stamp_of(&(&pad.radial, opener, pad.radial_stick, self.glyphs))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<usize, RadialRow>> {
let pages = &s.controller.radial;
let default = *pages == Pages::default();
let all = Group {
name: ALL_GROUP,
rows: vec![
Row::new(
RadialRow::Reset,
lang::get("horizon.radial.reset"),
Value::Press,
)
.about(lang::get("horizon.radial.reset.about"))
.blocked(default.then(|| lang::get("horizon.radial.reset.default"))),
opener_row(s, self.glyphs),
],
};
(0..pages.len())
.map(|page| {
let [slots, changes] = page_groups(pages, page);
let all = Group {
name: all.name,
rows: all.rows.clone(),
};
Sheet::new(
page,
page_label(pages.page(page)),
page_of(page, pages.len()),
vec![slots, changes, all],
)
})
.collect()
}
fn press(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Vec<Output> {
let pages = &mut s.controller.radial;
match row {
RadialRow::Slot(page, slot) => {
let next = step(pages.page(page).slot(slot), back);
pages.set_slot(page, slot, next);
}
RadialRow::Rename(page) => {
self.naming = Some(NameEntry::new(page, &page_label(pages.page(page))));
return vec![Output::Push(Page::ControllerRadialName)];
}
RadialRow::NewPage => self.show = pages.add(),
RadialRow::Delete(page) => {
pages.remove(page);
self.show = Some(pages.clamp(page));
}
RadialRow::Reset => {
*pages = Pages::default();
self.show = Some(0);
}
// The shell starts its wait (`gamepad::opener_refused` refused, Start cancels).
RadialRow::Opener => return Vec::new(),
}
vec![Output::Event(Event::Changed)]
}
/// A press on a slot opens the list of every action for it; Left and Right still step it.
fn choose(&mut self, _s: &mut Settings, row: RadialRow) -> Option<Vec<Output>> {
let RadialRow::Slot(page, slot) = row else {
return None;
};
self.picking = Some((page, slot));
Some(vec![Output::Push(Page::ControllerRadialPick)])
}
/// The wheel over a slot steps it, as a press does.
fn wheel_step(&mut self, s: &mut Settings, row: RadialRow, back: bool) -> Option<Vec<Output>> {
matches!(row, RadialRow::Slot(..)).then(|| self.press(s, row, back))
}
/// Undo is how a delete or a reset is taken back: they act at once.
fn undoes(&self) -> bool {
true
}
/// The pages, and the opener's button, which its row binds.
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.controller.radial = before.controller.radial.clone();
s.controller.bind(OPENER, before.controller.binding(OPENER));
Vec::new()
}
}
const PICK_TITLE: &str = "horizon.radial.pick_title";
const PICK_GROUP: &str = "horizon.radial.group.actions";
/// A row of a slot's list: the line naming the slot, then Empty and every action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PickRow {
Slot,
Pick(Option<RadialAction>),
}
/// One slot's list of every action and Empty, on the settings shell: it names the slot, marks
/// what the slot holds and opens on it; a press puts that in the slot and goes back.
#[derive(Default)]
pub(crate) struct PickScreen {
/// The page and the slot.
at: (usize, usize),
picked: bool,
}
impl PickScreen {
/// For `at`; the row the focus opens on, what the slot holds.
pub(crate) fn open_on(&mut self, at: (usize, usize), pages: &Pages) -> PickRow {
*self = Self { at, picked: false };
self.current(pages).unwrap_or(PickRow::Pick(None))
}
/// The page and the slot it is for.
pub(crate) fn at(&self) -> (usize, usize) {
self.at
}
/// A pick changed the pages since the last call, which the editor's Undo takes back.
pub(crate) fn take_picked(&mut self) -> bool {
std::mem::take(&mut self.picked)
}
/// The row of what the slot holds; none for an action this game doesn't know.
fn current(&self, pages: &Pages) -> Option<PickRow> {
match pages.page(self.at.0).slot(self.at.1) {
Slot::Empty => Some(PickRow::Pick(None)),
Slot::Action(action) => Some(PickRow::Pick(Some(*action))),
Slot::Missing(_) => None,
}
}
}
impl Screen for PickScreen {
type Page = ();
type Row = PickRow;
fn title(&self) -> &'static str {
PICK_TITLE
}
fn stamp(&self, s: &Settings) -> u64 {
let (page, slot) = self.at;
shell::stamp_of(&(self.at, s.controller.radial.page(page).slot(slot)))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), PickRow>> {
let pages = &s.controller.radial;
let (page, slot) = self.at;
let direction = lang::get(&format!("horizon.radial.slot.{slot}"));
let named = lang::tr(
"horizon.radial.pick.slot",
&[&direction, &page_label(pages.page(page))],
);
let current = self.current(pages);
let about = lang::get("horizon.radial.pick.about");
let mut rows =
vec![Row::new(PickRow::Slot, named.clone(), Value::Note).about(about.clone())];
for action in std::iter::once(None).chain(RadialAction::ALL.map(Some)) {
let id = PickRow::Pick(action);
let value = if current == Some(id) {
Value::Choice(lang::get("horizon.radial.pick.current"))
} else {
Value::Press
};
let name = slot_label(&action.into());
rows.push(Row::new(id, name, value).about(about.clone()));
}
vec![Sheet::new(
(),
named,
about,
vec![Group {
name: PICK_GROUP,
rows,
}],
)]
}
fn press(&mut self, s: &mut Settings, row: PickRow, _back: bool) -> Vec<Output> {
let PickRow::Pick(action) = row else {
return Vec::new();
};
let (page, slot) = self.at;
s.controller.radial.set_slot(page, slot, action.into());
self.picked = true;
vec![Output::Event(Event::Changed), Output::Done]
}
}
/// The name field and its buttons, centred in the screen but under the title, one row apart.
const NAME_W: f32 = 200.0;
const NAME_BLOCK_H: f32 = 100.0;
const NAME_TOP_MIN: f32 = 30.0;
const NAME_STEP: f32 = 25.0;
/// What a press on the name's page did.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Named {
Nothing,
/// Done: the page takes the name.
Done,
/// Cancel, keeping the old name.
Back,
}
/// A page's name being typed.
pub(crate) struct NameEntry {
page: usize,
field: ui::EditBox,
}
impl NameEntry {
fn new(page: usize, name: &str) -> Self {
let mut field = ui::EditBox::new(0.0, 0.0, NAME_W, BUTTON_H);
field.max_length = radial::NAME_MAX;
field.set_value(name);
field.focused = true;
Self { page, field }
}
/// Done and Cancel, under the field.
fn buttons(w: f32, h: f32) -> [([f32; 4], Named); 2] {
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
let at = |row: f32| [x, top + row * NAME_STEP, NAME_W, BUTTON_H];
[(at(1.0), Named::Done), (at(2.0), Named::Back)]
}
pub(crate) fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
in_world: bool,
) {
let (w, h) = (batch.width.ceil(), batch.height.ceil());
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(lang::get("horizon.radial.name_title"));
style::title(batch, atlas, &title);
let field = &mut self.field;
let (x, top) = ((w / 2.0).floor() - NAME_W / 2.0, name_top(h));
(field.x, field.y, field.width, field.height) = (x, top, NAME_W, BUTTON_H);
field.draw(batch, atlas, None);
for ([x, y, bw, bh], named) in Self::buttons(w, h) {
let (label, action) = match named {
Named::Done => ("gui.done", crate::widgets::Action::Done),
_ => ("gui.cancel", crate::widgets::Action::Cancel),
};
let hover = ui::hovered(cursor, x, y, bw, bh);
let text = Text::literal(lang::get(label));
ui::action_button(batch, atlas, action, x, y, bw, bh, &text, true, hover);
}
}
/// A press at `(x, y)` on a screen `w` x `h`.
pub(crate) fn click(
&mut self,
atlas: &HudAtlas,
(x, y): (f32, f32),
(w, h): (f32, f32),
) -> Named {
let hit = Self::buttons(w, h)
.into_iter()
.find(|([bx, by, bw, bh], _)| ui::hovered((x, y), *bx, *by, *bw, *bh));
if let Some((_, named)) = hit {
return named;
}
let field = &mut self.field;
field.focused = ui::hovered((x, y), field.x, field.y, field.width, field.height);
if field.focused {
field.click(atlas, x, false, false);
}
Named::Nothing
}
/// The name field takes the keys.
pub(crate) fn typing(&self) -> bool {
self.field.focused
}
pub(crate) fn type_text(&mut self, text: &str) {
if self.field.focused {
self.field.insert_text(text);
}
}
/// A key: Enter is Done, Escape is Cancel, the rest the field's.
pub(crate) fn key(&mut self, key: winit::keyboard::KeyCode, text: Option<&str>) -> Named {
use winit::keyboard::KeyCode as K;
match key {
K::Escape => return Named::Back,
K::Enter | K::NumpadEnter => return Named::Done,
_ if self.field.focused => {
if !self.field.key(key, false, false)
&& let Some(t) = text
{
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
self.field.insert_text(&typed);
}
}
_ => {}
}
Named::Nothing
}
/// Ctrl+A/C/V/X in the name field; false when it hasn't the focus.
pub(crate) fn shortcut(
&mut self,
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
if self.field.focused {
self.field.shortcut(shortcut, clipboard);
}
self.field.focused
}
/// The name into the pages. Left as it was, a default page keeps following the language.
pub(crate) fn apply(&self, pages: &mut Pages) -> bool {
let changed = self.field.value != page_label(pages.page(self.page));
if changed {
pages.rename(self.page, &self.field.value);
}
changed
}
}
/// The name field's top.
fn name_top(h: f32) -> f32 {
((h - NAME_BLOCK_H) / 2.0).floor().max(NAME_TOP_MIN)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gamepad::Button as PadButton;
use crate::radial::PageName;
use crate::settings_shell::{Hosted, Shell};
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
fn rows_of(s: &Settings, page: usize) -> Vec<Row<RadialRow>> {
let sheets = RadialScreen::default().sheets(s);
let groups = sheets.into_iter().nth(page).unwrap().groups;
groups.into_iter().flat_map(|group| group.rows).collect()
}
fn row(s: &Settings, id: RadialRow) -> Row<RadialRow> {
let page = match id {
RadialRow::Slot(page, _) | RadialRow::Rename(page) | RadialRow::Delete(page) => page,
_ => 0,
};
rows_of(s, page).into_iter().find(|r| r.id == id).unwrap()
}
/// A click on `id` through the shell, as the mouse or a controller's A gives it.
fn click(shell: &mut Shell<RadialScreen>, s: &mut Settings, id: RadialRow) -> Vec<Output> {
let at = shell.point_at(s, SIZE, id);
shell.click(s, at, SIZE, false, false, false)
}
/// Undo, at the foot over Done.
fn undo(shell: &mut Shell<RadialScreen>, s: &mut Settings) {
let [x, y, ..] = shell::Columns::of(SIZE.0, SIZE.1).button(1);
shell.click(s, (x + 2.0, y + 2.0), SIZE, false, false, false);
}
#[test]
fn a_slot_steps_through_empty_and_every_action_both_ways() {
let mut seen = vec![Slot::Empty];
let mut slot = Slot::Empty;
for _ in RadialAction::ALL {
slot = step(&slot, false);
seen.push(slot.clone());
}
assert_eq!(step(&slot, false), Slot::Empty, "the last wraps to Empty");
let actions: Vec<_> = seen.iter().filter_map(Slot::action).collect();
assert_eq!(actions, RadialAction::ALL.to_vec());
assert_eq!(
step(&Slot::Empty, true).action(),
RadialAction::ALL.last().copied()
);
let chat = Slot::Action(RadialAction::Chat);
assert_eq!(step(&step(&chat, true), false), chat);
// A missing action steps on as Empty would.
let missing = Slot::Missing("mymod:dash".to_owned());
assert_eq!(step(&missing, false), step(&Slot::Empty, false));
}
#[test]
fn a_press_on_a_slot_opens_its_list_and_left_and_right_still_step_it() {
let mut s = Settings::default();
let mut shell = Shell::<RadialScreen>::default();
let before = s.controller.radial.clone();
let outputs = click(&mut shell, &mut s, RadialRow::Slot(0, 2));
assert!(outputs.contains(&Output::Push(Page::ControllerRadialPick)));
assert_eq!(shell.screen.picking, Some((0, 2)));
assert_eq!(
s.controller.radial, before,
"the press itself changes nothing"
);
// Right on the focused row steps it.
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 2))));
shell.key(&mut s, K::ArrowRight, None, false, SIZE);
let stepped = step(before.page(0).slot(2), false);
assert_eq!(*s.controller.radial.page(0).slot(2), stepped);
}
#[test]
fn the_list_names_the_slot_marks_its_action_and_a_pick_fills_it() {
let mut s = Settings::default();
let mut picker = PickScreen::default();
// General's right slot holds Commands.
let opens_on = picker.open_on((0, 2), &s.controller.radial);
assert_eq!(opens_on, PickRow::Pick(Some(RadialAction::Command)));
let rows: Vec<_> = picker
.sheets(&s)
.into_iter()
.flat_map(|sheet| sheet.groups)
.flat_map(|group| group.rows)
.collect();
assert_eq!(rows[0].id, PickRow::Slot);
assert_eq!(rows[0].value, Value::Note);
// Empty, then every action, each once; only the slot's own marked.
let picks: Vec<_> = rows[1..].iter().map(|row| row.id).collect();
let every: Vec<_> = std::iter::once(None)
.chain(RadialAction::ALL.map(Some))
.map(PickRow::Pick)
.collect();
assert_eq!(picks, every);
let marked: Vec<_> = rows
.iter()
.filter(|row| matches!(row.value, Value::Choice(_)))
.map(|row| row.id)
.collect();
assert_eq!(marked, [opens_on]);
let outputs = picker.press(&mut s, PickRow::Pick(Some(RadialAction::Chat)), false);
assert_eq!(outputs, [Output::Event(Event::Changed), Output::Done]);
assert_eq!(
*s.controller.radial.page(0).slot(2),
Slot::Action(RadialAction::Chat)
);
assert!(picker.take_picked() && !picker.take_picked());
// Empty empties it.
picker.press(&mut s, PickRow::Pick(None), false);
assert!(s.controller.radial.page(0).slot(2).is_empty());
}
#[test]
fn a_missing_action_says_which_it_is() {
let mut s = Settings::default();
s.controller
.radial
.set_slot(0, 1, Slot::Missing("mymod:dash".to_owned()));
let english = lang::Language::load(&[], "en_us");
let (chosen, _) = lang::scoped(english, || row(&s, RadialRow::Slot(0, 1)).chosen);
assert!(chosen.is_some_and(|text| text.contains("mymod:dash")));
}
#[test]
fn a_slot_changes_alone_and_undo_puts_back_what_the_screen_found() {
let mut s = Settings::default();
let mut shell = Shell::<RadialScreen>::default();
let before = s.controller.radial.page(0).clone();
shell.set_focus(Some(shell::Target::Row(RadialRow::Slot(0, 0))));
let outputs = shell.key(&mut s, K::ArrowRight, None, false, SIZE);
assert!(outputs.contains(&Output::Event(Event::Changed)));
let after = s.controller.radial.page(0);
assert_eq!(*after.slot(0), step(before.slot(0), false));
assert_eq!(after.slots()[1..], before.slots()[1..]);
assert!(shell.changed());
undo(&mut shell, &mut s);
assert_eq!(s.controller.radial, Pages::default());
assert!(!shell.changed());
}
#[test]
fn delete_and_reset_act_at_once_and_undo_takes_them_back() {
let mut s = Settings::default();
s.controller.radial.rename(1, "Mine");
let found = s.controller.radial.clone();
let mut shell = Shell::<RadialScreen>::default();
click(&mut shell, &mut s, RadialRow::Delete(0));
assert_eq!(s.controller.radial.len(), 1);
assert_eq!(page_label(s.controller.radial.page(0)), "Mine");
// One page left: it can't go, and the strip says why.
let delete = row(&s, RadialRow::Delete(0));
assert!(!delete.live && delete.why.is_some());
let reset = row(&s, RadialRow::Reset);
assert!(reset.live);
shell.screen.take_shown();
click(&mut shell, &mut s, RadialRow::Reset);
assert_eq!(s.controller.radial, Pages::default());
assert!(!row(&s, RadialRow::Reset).live, "already the defaults");
assert_eq!(shell.screen.take_shown(), Some(0));
undo(&mut shell, &mut s);
assert_eq!(s.controller.radial, found);
}
#[test]
fn new_pages_stop_at_the_most_and_the_new_one_shows() {
let mut s = Settings::default();
let mut screen = RadialScreen::default();
while row(&s, RadialRow::NewPage).live {
screen.press(&mut s, RadialRow::NewPage, false);
assert_eq!(screen.take_shown(), Some(s.controller.radial.len() - 1));
}
assert_eq!(s.controller.radial.len(), radial::MAX_PAGES);
assert!(row(&s, RadialRow::NewPage).why.is_some());
// Every page is a sheet, named as the overlay names it.
let sheets = screen.sheets(&s);
assert_eq!(sheets.len(), radial::MAX_PAGES);
assert_eq!(sheets[0].name, page_label(s.controller.radial.page(0)));
}
#[test]
fn a_deleted_last_page_leaves_the_one_before_it_showing() {
let mut s = Settings::default();
let mut screen = RadialScreen::default();
screen.press(&mut s, RadialRow::Delete(1), false);
assert_eq!(screen.take_shown(), Some(0));
}
#[test]
fn rename_opens_the_name_page_and_only_done_with_a_new_name_renames() {
let general = Pages::default().page(0).name.clone();
let mut s = Settings::default();
let mut screen = RadialScreen::default();
let outputs = screen.press(&mut s, RadialRow::Rename(0), false);
assert_eq!(outputs, vec![Output::Push(Page::ControllerRadialName)]);
let entry = screen.naming.as_mut().unwrap();
// Done with the name as it was: a default page keeps following the language.
assert_eq!(entry.key(K::Enter, None), Named::Done);
assert!(!entry.apply(&mut s.controller.radial));
assert_eq!(s.controller.radial.page(0).name, general);
entry.type_text("!");
assert_eq!(entry.key(K::Escape, None), Named::Back);
entry.field.set_value("Mine");
entry.type_text("!");
assert!(entry.apply(&mut s.controller.radial));
assert_eq!(
s.controller.radial.page(0).name,
PageName::Custom("Mine!".to_owned())
);
}
#[test]
fn undo_puts_back_a_name_given_on_the_name_page() {
let mut s = Settings::default();
s.controller.radial.rename(1, "Mine");
let mut shell = Shell::<RadialScreen>::default();
shell.show(1);
click(&mut shell, &mut s, RadialRow::Rename(1));
let entry = shell.screen.naming.as_mut().unwrap();
entry.field.set_value("Building");
// As the options screen does on Done: the name page changed what Undo puts back.
let entry = shell.screen.naming.take().unwrap();
assert!(entry.apply(&mut s.controller.radial));
shell.changed_elsewhere();
assert_eq!(page_label(s.controller.radial.page(1)), "Building");
assert!(shell.changed());
undo(&mut shell, &mut s);
assert_eq!(page_label(s.controller.radial.page(1)), "Mine");
assert!(!shell.changed());
}
#[test]
fn a_nintendo_pad_going_by_its_letters_names_its_own_opener() {
let mut s = Settings::default();
// Selecting with the left stick, the top face button (North) can open it.
s.controller.radial_stick = gamepad::RadialStick::Left;
s.controller.bind(OPENER, Some(gamepad::Button::North));
s.controller.glyphs = gamepad::GlyphStyle::Nintendo;
let label = |s: &Settings| {
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Nintendo);
match opener_row(s, glyphs).value {
Value::Binding(Binding {
shown: Shown::Pad { glyph, .. },
..
}) => glyph.expect("bound").label().to_owned(),
other => panic!("{other:?}"),
}
};
// By places, the top button: printed X on a Nintendo pad.
s.controller.nintendo_letters = false;
assert_eq!(label(&s), "X");
// By letters, the part is played by the button printed Y (on the left).
s.controller.nintendo_letters = true;
assert_eq!(label(&s), "Y");
}
#[test]
fn the_opener_row_names_its_button_and_says_what_gets_in_the_way() {
let mut s = Settings::default();
let glyphs = Glyphs::of(&s.controller, gamepad::GlyphStyle::Xbox);
let shown = |s: &Settings| match opener_row(s, glyphs).value {
Value::Binding(Binding {
capture,
shown: Shown::Pad { glyph, .. },
..
}) => {
assert_eq!(capture, Capture::Pad(OPENER));
glyph
}
other => panic!("{other:?}"),
};
let bound = s.controller.binding(OPENER).unwrap();
// The button on its glyph.
let glyph = shown(&s).expect("the opener's glyph");
assert_eq!(glyph.part(), bound);
assert_eq!(glyph.label(), "→");
assert_eq!(opener_row(&s, glyphs).chosen, None);
s.controller.bind("key.chat", Some(bound));
let shared = opener_row(&s, glyphs).chosen;
assert!(shared.is_some(), "a shared button");
s.controller.bind(OPENER, None);
let unbound = opener_row(&s, glyphs).chosen;
assert!(unbound.is_some() && unbound != shared && shown(&s).is_none());
// A press waits for a button on the row, and Undo takes a new one back.
let mut shell = Shell::<RadialScreen>::default();
s.controller.bind(OPENER, Some(bound));
let outputs = click(&mut shell, &mut s, RadialRow::Opener);
assert!(outputs.contains(&Output::Bind(Capture::Pad(OPENER))));
assert!(shell.changed());
s.controller.bind(OPENER, Some(PadButton::North));
undo(&mut shell, &mut s);
assert_eq!(s.controller.binding(OPENER), Some(bound));
}
#[test]
fn every_string_has_a_horizon_key() {
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let english = lang::Language::load(&[], "en_us");
let ((), missed) = lang::scoped(english, || {
let mut s = Settings::default();
s.controller.bind("key.chat", Some(PadButton::DPadRight));
let mut shell = Shell::<RadialScreen>::default();
let draw = |shell: &mut Shell<RadialScreen>, s: &Settings| {
let mut batch = HudBatch::new([854, 480]);
shell.draw(s, &mut batch, &atlas, (0.0, 0.0), false, None);
};
// Every page's rows are built, with what the strip says of each.
draw(&mut shell, &s);
s.controller.bind(OPENER, None);
s.controller.radial.remove(1);
while s.controller.radial.add().is_some() {}
draw(&mut shell, &s);
s.controller.radial = Pages::default();
s.controller.radial.remove(1);
draw(&mut shell, &s);
let mut batch = HudBatch::new([854, 480]);
let waiting = Some(Capture::Pad(OPENER));
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, waiting);
let mut entry = NameEntry::new(0, "Mine");
let mut batch = HudBatch::new([854, 480]);
entry.draw(&mut batch, &atlas, (0.0, 0.0), false);
let mut picker = Shell::<PickScreen>::default();
picker.screen.open_on((0, 1), &s.controller.radial);
let mut batch = HudBatch::new([854, 480]);
picker.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
});
// Vanilla's own (gui.done...) come from the game's resources, not bundled ones.
let missed: Vec<_> = missed
.iter()
.filter(|key| key.starts_with("horizon.") || !key.contains('.'))
.collect();
assert!(missed.is_empty(), "no text for {missed:?}");
}
}
File diff suppressed because it is too large. Load diff
@@ -203,9 +203,6 @@ pub struct Settings {
pub startup_api: crate::graphics::GraphicsApi,
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
pub video_modes: Vec<crate::window::VideoMode>,
/// Fullbright is held off: this window plays on another's server (set by the game, not
/// saved). The graphics screen hides its row; `extras.fullbright` keeps the choice.
pub fullbright_held: bool,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
pub controller: crate::gamepad::PadSettings,
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
@@ -296,7 +293,6 @@ impl Default for Settings {
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
fullbright_held: false,
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
@@ -518,7 +514,6 @@ impl Settings {
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
fullbright_held: false,
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
@@ -874,10 +869,6 @@ pub enum Opt {
PadNintendoButtons,
PadKeyboard,
PadGameplayGuide,
/// The radial menu's opener held or pressed once (`PadSettings::radial_mode`).
PadRadialMode,
/// The stick the radial menu selects with (`PadSettings::radial_stick`).
PadRadialStick,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
File diff suppressed because it is too large. Load diff
@@ -1,815 +0,0 @@
use super::*;
use crate::options_ui::Event;
use winit::keyboard::KeyCode as K;
const SIZE: (f32, f32) = (854.0, 480.0);
const SHORT: (f32, f32) = (480.0, 240.0);
const CHANGED: Output = Output::Event(Event::Changed);
/// A screen with what Extras doesn't have: two pages, a slider, a value with descriptions of
/// its own, and a row a wheel steps.
#[derive(Default)]
struct Probe {
slider: f32,
choice: usize,
pressed: Vec<(u32, bool)>,
/// How many times its rows were built.
built: std::cell::Cell<usize>,
}
const CHOICES: [(&str, &str); 2] = [("Red", "Paints it red."), ("Blue", "Paints it blue.")];
const SLIDER: u32 = 0;
const CHOICE: u32 = 1;
const FIRST_FILLER: u32 = 10;
const FILLERS: u32 = 40;
const GREY: u32 = 100;
const WHY: &str = "Not here.";
impl Screen for Probe {
type Page = u8;
type Row = u32;
fn title(&self) -> &'static str {
"Probe"
}
/// Its rows read the field of view, besides its own state.
fn stamp(&self, s: &Settings) -> u64 {
stamp_of(&s.fov)
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<u8, u32>> {
self.built.set(self.built.get() + 1);
let (value, chosen) = CHOICES[self.choice];
let mut rows = vec![
Row::new(
SLIDER,
"Level".to_owned(),
Value::Slider {
position: self.slider,
steps: Some(10),
text: s.fov.to_string(),
},
),
Row {
chosen: Some(chosen.to_owned()),
..Row::new(CHOICE, "Paint".to_owned(), Value::Choice(value.to_owned()))
.about("Picks a colour.".to_owned())
},
Row::new(GREY, "Greyed".to_owned(), Value::Press)
.about("Does a thing.".to_owned())
.blocked(Some(WHY.to_owned())),
];
rows.extend((0..FILLERS).map(|i| {
Row::new(FIRST_FILLER + i, format!("Filler {i}"), Value::Press)
.about("Does nothing.".to_owned())
}));
let elsewhere = vec![Row::new(99, "Elsewhere".to_owned(), Value::Press)];
vec![
Sheet::new(
0,
"One".to_owned(),
"The first.".to_owned(),
vec![Group { name: "G", rows }],
),
Sheet::new(
1,
"Two".to_owned(),
"The second.".to_owned(),
vec![Group {
name: "H",
rows: elsewhere,
}],
),
]
}
fn press(&mut self, s: &mut Settings, row: u32, back: bool) -> Vec<Output> {
self.pressed.push((row, back));
match row {
CHOICE => self.choice = (self.choice + 1) % CHOICES.len(),
FIRST_FILLER => s.fov += 1.0,
_ => {}
}
vec![CHANGED]
}
fn undoes(&self) -> bool {
true
}
fn undo(&mut self, s: &mut Settings, before: &Settings) -> Vec<Output> {
s.fov = before.fov;
Vec::new()
}
fn slide(&mut self, _: &mut Settings, _: u32, position: f32) {
self.slider = position;
}
fn wheel_step(&mut self, _: &mut Settings, row: u32, _: bool) -> Option<Vec<Output>> {
(row == CHOICE).then(|| {
self.choice = (self.choice + 1) % CHOICES.len();
vec![CHANGED]
})
}
}
fn middle_of(shell: &mut Shell<Probe>, row: u32, size: (f32, f32)) -> (f32, f32) {
shell.point_at(&Settings::default(), size, row)
}
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// What one frame asks of a screen: a draw, then the stick's scroll, an arrow key and the wheel
/// away from any row, at the size it was drawn at.
fn frame(shell: &mut Shell<Probe>, s: &mut Settings, atlas: &HudAtlas) -> (f32, f32) {
let mut batch = HudBatch::new([1708, 960]);
shell.draw(s, &mut batch, atlas, (0.0, 0.0), false, None);
let size = (batch.width, batch.height);
shell.scroll(s, size, -1.0);
shell.key(s, K::ArrowDown, None, false, size);
shell.wheel(s, size, (0.0, 0.0), 1.0);
size
}
#[test]
fn rows_are_built_once_and_again_only_when_what_they_read_changes() {
let atlas = atlas();
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let built = |shell: &Shell<Probe>| shell.screen.built.get();
let reads = |shell: &mut Shell<Probe>, s: &Settings, size, end: &str| {
let view = shell.view(s, size);
let mut labels = view.rows.iter().map(|r| r.label(r.value_text().as_deref()));
labels.any(|label| label.ends_with(end))
};
// Each of the four built them before: now the first frame builds them once, the next none.
let size = frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), 1);
// A press changes the screen's own state: its row reads the new value.
let at = middle_of(&mut shell, CHOICE, size);
shell.click(&mut s, at, size, false, false, false);
assert!(reads(&mut shell, &s, size, "Blue"));
// A setting changed elsewhere (a key bound, a command) shows on the next frame.
s.fov = 90.0;
frame(&mut shell, &mut s, &atlas);
assert!(reads(&mut shell, &s, size, "90"));
let before = built(&shell);
frame(&mut shell, &mut s, &atlas);
assert_eq!(built(&shell), before);
// Searching, and another size, build them again.
shell.type_text("filler");
shell.view(&s, size);
assert_eq!(built(&shell), before + 1);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 2);
// So does another language, and coming back from it.
lang::scoped(lang::Language::load(&[], "de_de"), || shell.view(&s, SHORT));
assert_eq!(built(&shell), before + 3);
shell.view(&s, SHORT);
assert_eq!(built(&shell), before + 4);
}
#[test]
fn a_row_the_list_hides_part_of_scrolls_into_view_when_focused_and_then_takes_a_press() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let cols = Columns::of(SHORT.0, SHORT.1);
// A filler far down, scrolled to just over the bottom: its middle is under the list's edge.
let cut = FIRST_FILLER + 20;
let view = shell.view(&s, SHORT);
let (_, y) = shell.placed(&view).find(|(r, _)| r.id == cut).unwrap();
shell.scroll.amount = y - cols.bottom + style::ROW / 2.0 - 1.0;
let rect = cols.row(y - shell.scroll.amount);
assert!(
rect[1] + rect[3] / 2.0 >= cols.bottom,
"the row's middle is hidden"
);
let moved = Hosted::reveal(&mut shell, &s, SHORT, rect).unwrap();
let shown = [rect[0], rect[1] + moved, rect[2], rect[3]];
assert!(moved < 0.0 && shown[1] + shown[3] <= cols.bottom);
// All in view, it stays put.
assert_eq!(Hosted::reveal(&mut shell, &s, SHORT, shown), None);
let middle = (shown[0] + 4.0, shown[1] + shown[3] / 2.0);
assert_eq!(
shell.click(&mut s, middle, SHORT, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!(shell.screen.pressed, [(cut, false)]);
}
#[test]
fn undo_appears_after_a_change_and_puts_back_what_the_screen_opened_with() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let undo = Columns::of(SIZE.0, SIZE.1).button(1);
let on_undo = (undo[0] + 2.0, undo[1] + 2.0);
// Nothing changed: there is no Undo to press.
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(s.fov, 71.0);
// Another option changed meanwhile isn't the screen's to put back.
s.chat_opacity = 0.25;
assert_eq!(
shell.click(&mut s, on_undo, SIZE, false, false, false),
vec![CLICK, CHANGED]
);
assert_eq!((s.fov, s.chat_opacity), (70.0, 0.25));
assert!(
shell
.click(&mut s, on_undo, SIZE, false, false, false)
.is_empty()
);
// From the keyboard: Tab reaches it, Enter undoes and leaves the focus on Done.
shell.click(&mut s, at, SIZE, false, false, false);
let reached = (0..100).any(|_| {
shell.key(&mut s, K::Tab, None, false, SIZE);
shell.focus == Some(Target::Undo)
});
assert!(reached);
assert_eq!(
shell.key(&mut s, K::Enter, None, false, SIZE),
vec![CLICK, CHANGED]
);
assert_eq!(s.fov, 70.0);
assert_eq!(shell.focus, Some(Target::Done));
}
#[test]
fn the_strip_says_why_a_greyed_row_cant_be_used() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
let size = (batch.width, batch.height);
let strip = |batch: &mut HudBatch, shell: &mut Shell<Probe>, at| {
shell.draw(&s, batch, &atlas, at, false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
let dock = Columns::of(size.0, size.1).dock;
let labelled = crate::widgets::last_labelled();
let found = labelled.iter().find(|w| [w.x, w.y, w.w, w.h] == dock);
found.map(|w| w.label.clone())
};
let grey = middle_of(&mut shell, GREY, size);
assert_eq!(strip(&mut batch, &mut shell, grey).as_deref(), Some(WHY));
let choice = middle_of(&mut shell, CHOICE, size);
assert_eq!(
strip(&mut batch, &mut shell, choice).as_deref(),
Some(CHOICES[0].1)
);
}
#[test]
fn growing_the_window_keeps_the_scroll_within_the_list() {
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
shell.scroll(&s, SIZE, -1000.0);
assert!(shell.scroll.amount > 0.0);
let tall = (SIZE.0, 4000.0);
let view = shell.view(&s, tall);
assert_eq!(shell.scroll.amount, 0.0);
let first = shell.placed(&view).next().map(|(_, y)| y).unwrap();
assert!(first >= view.cols.top);
}
#[test]
fn one_scroll_path_moves_the_list_for_wheel_and_stick_alike() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let rate = SCROLL_RATE;
// The wheel away from any row, and `scroll`, which a stick and a D-pad use.
shell.wheel(&mut s, SIZE, (0.0, 0.0), -1.0);
assert_eq!(shell.scroll.amount, rate);
shell.scroll(&s, SIZE, -2.0);
assert_eq!(shell.scroll.amount, 3.0 * rate);
shell.scroll(&s, SIZE, 100.0);
assert_eq!(shell.scroll.amount, 0.0);
}
#[test]
fn the_wheel_steps_a_row_only_with_the_extra_on_and_only_where_the_screen_says() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let over_choice = middle_of(&mut shell, CHOICE, SIZE);
shell.wheel(&mut s, SIZE, over_choice, -1.0);
assert_eq!(shell.screen.choice, 0);
assert_eq!(shell.scroll.amount, SCROLL_RATE);
s.extras.wheel_cycles_settings = true;
shell.scroll.amount = 0.0;
let outputs = shell.wheel(&mut s, SIZE, over_choice, -1.0);
assert_eq!(outputs, vec![CHANGED]);
assert_eq!(shell.screen.choice, 1);
assert_eq!(shell.scroll.amount, 0.0);
// A row the screen doesn't step scrolls, the greyed one too.
let over_grey = middle_of(&mut shell, GREY, SIZE);
assert!(shell.wheel(&mut s, SIZE, over_grey, -1.0).is_empty());
assert_eq!(shell.scroll.amount, SCROLL_RATE);
}
#[test]
fn a_row_says_what_its_chosen_value_does_and_is_found_by_what_it_does() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let about = |shell: &mut Shell<Probe>, s: &Settings| {
let view = shell.view(s, SIZE);
let row = view.rows.iter().find(|r| r.id == CHOICE).unwrap();
row.described().to_owned()
};
assert_eq!(about(&mut shell, &s), "Paints it red.");
let at = middle_of(&mut shell, CHOICE, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert_eq!(about(&mut shell, &s), "Paints it blue.");
// The search reads the row's own description, on every page.
shell.type_text("colour");
let view = shell.view(&s, SIZE);
let found: Vec<u32> = view.rows.iter().map(|r| r.id).collect();
assert!(found.contains(&CHOICE) && !found.contains(&99));
shell.search.set_value("elsewhere");
shell.focus = None;
let view = shell.view(&s, SIZE);
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
}
#[test]
fn a_press_on_the_track_slides_and_on_the_name_only_focuses() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, SLIDER, SIZE);
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert_eq!(shell.screen.slider, 0.0);
let (track, width) = Columns::of(SIZE.0, SIZE.1).track();
let mid = (track + width / 2.0, at.1);
assert_eq!(
shell.click(&mut s, mid, SIZE, false, false, false),
vec![DRAGGED]
);
assert_eq!(shell.screen.slider, 0.5);
let end = (track + width, at.1);
assert_eq!(
shell.click(&mut s, end, SIZE, true, false, false),
vec![DRAGGED]
);
assert_eq!(shell.screen.slider, 1.0);
assert!(shell.release().is_some());
assert!(shell.release().is_none());
}
#[test]
fn the_keyboard_walks_the_live_rows_past_the_view_and_presses_them() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SHORT);
// Tab: the search, the two pages, then the first row.
for _ in 0..4 {
key(&mut shell, K::Tab);
}
assert_eq!(shell.focus, Some(Target::Row(SLIDER)));
// A slider Tab reaches changes with the arrows, in its steps.
key(&mut shell, K::ArrowRight);
assert!((shell.screen.slider - 0.1).abs() < f32::EPSILON);
// The arrows go on past the greyed row and rows out of view, which scroll in.
key(&mut shell, K::ArrowDown);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
key(&mut shell, K::ArrowDown);
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER)));
for _ in 0..30 {
key(&mut shell, K::ArrowDown);
}
assert_eq!(shell.focus, Some(Target::Row(FIRST_FILLER + 30)));
assert!(shell.scroll.amount > 0.0);
assert_eq!(key(&mut shell, K::Enter), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(FIRST_FILLER + 30, false)]);
assert_eq!(key(&mut shell, K::Escape), vec![Output::Done]);
}
#[test]
fn left_and_right_step_a_focused_choice_and_leave_other_rows() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut key = |shell: &mut Shell<Probe>, k| shell.key(&mut s, k, None, false, SIZE);
shell.set_focus(Some(Target::Row(CHOICE)));
// Right is a press, Left a Shift press: the screen's own way back, and the focus stays.
assert_eq!(key(&mut shell, K::ArrowRight), vec![CLICK, CHANGED]);
assert_eq!(key(&mut shell, K::ArrowLeft), vec![CLICK, CHANGED]);
assert_eq!(shell.screen.pressed, [(CHOICE, false), (CHOICE, true)]);
assert_eq!(shell.focus, Some(Target::Row(CHOICE)));
assert!(shell.changed);
// A row that isn't a choice: Left goes to the page list, as before.
shell.set_focus(Some(Target::Row(FIRST_FILLER)));
assert!(key(&mut shell, K::ArrowLeft).is_empty());
assert!(matches!(shell.focus, Some(Target::Page(_))));
assert_eq!(shell.screen.pressed.len(), 2);
}
#[test]
fn a_drawn_choice_tells_the_pad_its_arrows_step_it() {
let atlas = atlas();
let s = Settings::default();
let mut shell = Shell::<Probe>::default();
let mut batch = HudBatch::new([1708, 960]);
shell.draw(&s, &mut batch, &atlas, (0.0, 0.0), false, None);
crate::widgets::end_frame(u64::MAX);
let view = shell.view(&s, (batch.width, batch.height));
let rect_of = |id| {
let mut placed = shell.placed(&view);
view.cols.row(placed.find(|(r, _)| r.id == id).unwrap().1)
};
assert!(crate::widgets::last_steps_value(rect_of(CHOICE)));
assert!(!crate::widgets::last_steps_value(rect_of(SLIDER)));
assert!(!crate::widgets::last_steps_value(rect_of(FIRST_FILLER)));
}
/// A controller presses with a click, then moves its own focus: only keys show this one.
#[test]
fn leaving_the_search_field_gives_the_keys_back() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let view = shell.view(&s, SIZE);
let [x, y, w, h] = view.cols.search();
shell.click(
&mut s,
(x + w / 2.0, y + h / 2.0),
SIZE,
false,
false,
false,
);
assert!(shell.typing());
Hosted::blur_search(&mut shell);
assert!(!shell.typing());
// Another focus isn't dropped.
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
Hosted::blur_search(&mut shell);
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
}
#[test]
fn a_key_that_moves_nothing_shows_no_focus() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
shell.key(&mut s, K::ShiftLeft, None, false, SIZE);
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
}
#[test]
fn a_cut_description_says_so() {
assert_eq!(cut_off("off, it never "), "off, it never…");
}
#[test]
fn a_click_leaves_no_focus_showing_until_a_key_moves_it() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, false);
assert!(!shell.shows_focus(Target::Row(FIRST_FILLER)));
// The next key goes on from the row clicked, and shows where it went.
shell.key(&mut s, K::ArrowDown, None, false, SIZE);
assert!(shell.shows_focus(Target::Row(FIRST_FILLER + 1)));
}
#[test]
fn a_taller_strip_takes_from_the_list_as_far_as_the_list_keeps_its_height() {
let (wide, base) = (
Columns::of(480.0, 270.0).with_note_lines(4),
Columns::of(480.0, 270.0),
);
assert_eq!(wide.note_lines, 4);
assert_eq!(wide.bottom, base.bottom - 2.0 * NOTE_LINE_H);
assert_eq!(wide.dock[1] + wide.dock[3], base.dock[1] + base.dock[3]);
assert_eq!(wide.button(0), base.button(0));
// A 720p window at scale 4 has room for one more line, not two.
let short = Columns::of(320.0, 180.0).with_note_lines(4);
assert_eq!(short.note_lines, 3);
assert!(short.bottom - short.top >= MIN_LIST_H);
}
#[test]
fn shift_presses_a_row_backwards() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, FIRST_FILLER, SIZE);
shell.click(&mut s, at, SIZE, false, false, true);
// The right button is not a press.
shell.click(&mut s, at, SIZE, false, true, false);
assert_eq!(shell.screen.pressed, [(FIRST_FILLER, true)]);
}
#[test]
fn a_click_on_a_greyed_row_does_nothing_and_done_leaves() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let at = middle_of(&mut shell, GREY, SIZE);
assert!(
shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(shell.screen.pressed.is_empty());
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).button(0);
let at = (x + w / 2.0, y + h / 2.0);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![CLICK, Output::Done]
);
}
#[test]
fn a_page_picked_shows_alone() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let [x, y, w, h] = Columns::of(SIZE.0, SIZE.1).page(1);
let at = (x + w / 2.0, y + h / 2.0);
assert_eq!(
shell.click(&mut s, at, SIZE, false, false, false),
vec![CLICK]
);
let view = shell.view(&s, SIZE);
assert_eq!(view.rows.iter().map(|r| r.id).collect::<Vec<_>>(), [99]);
}
/// A screen with more pages than a short view's page list has room for: a page of its own,
/// then a section of `PAGES` under a line, one more page out of the list that the section's
/// first page links to, and a change that waits for Apply.
#[derive(Default)]
struct Pages {
pending: bool,
applied: usize,
}
const PAGES: u8 = 12;
const HOME: u8 = 0;
const DEEP: u8 = 100;
const LINK: u32 = 1000;
const WAITS: u32 = 1001;
const SECTION: &str = "Section";
impl Screen for Pages {
type Page = u8;
type Row = u32;
fn title(&self) -> &'static str {
"Pages"
}
fn stamp(&self, _: &Settings) -> u64 {
stamp_of(&self.pending)
}
fn sheets(&self, _: &Settings) -> Vec<Sheet<u8, u32>> {
let page = |id: u8, name: String, rows| {
Sheet::new(id, name, String::new(), vec![Group { name: "G", rows }])
};
let change = Row::new(WAITS, "Change".to_owned(), Value::Press);
let mut sheets = vec![page(HOME, "Home".to_owned(), vec![change])];
for i in 1..=PAGES {
let mut rows = vec![Row::new(u32::from(i), format!("Row {i}"), Value::Press)];
if i == 1 {
rows.push(Row::new(LINK, "Deeper...".to_owned(), Value::Press));
}
let mut sheet = page(i, format!("Page {i}"), rows);
sheet.heading = (i == 1).then(|| SECTION.to_owned());
sheets.push(sheet);
}
let deep = Row::new(u32::from(DEEP), "Deep".to_owned(), Value::Press);
let mut deeper = page(DEEP, "Deeper".to_owned(), vec![deep]);
deeper.listed = false;
sheets.push(deeper);
sheets
}
fn press(&mut self, _: &mut Settings, row: u32, _: bool) -> Vec<Output> {
self.pending |= row == WAITS;
vec![CHANGED]
}
fn link(&self, row: u32) -> Option<u8> {
(row == LINK).then_some(DEEP)
}
fn undoes(&self) -> bool {
true
}
fn pending(&self, _: &Settings) -> bool {
self.pending
}
fn apply(&mut self, _: &mut Settings) -> Vec<Output> {
self.pending = false;
self.applied += 1;
vec![CHANGED]
}
}
/// A window of 854x960 (427x480 GUI px at its automatic scale) and one of 854x480 (427x240:
/// the page list has room for six places).
const TALL: [u32; 2] = [854, 960];
const SHORT_WINDOW: [u32; 2] = [854, 480];
/// One frame of `shell` in a window of `window`; the GUI size it was drawn at.
fn drawn<S: Screen + Default>(shell: &mut Shell<S>, s: &Settings, window: [u32; 2]) -> (f32, f32) {
let mut batch = HudBatch::new(window);
shell.draw(s, &mut batch, &atlas(), (0.0, 0.0), false, None);
crate::widgets::end_frame(crate::widgets::generation() + 1);
(batch.width, batch.height)
}
fn middle([x, y, w, h]: [f32; 4]) -> (f32, f32) {
(x + w / 2.0, y + h / 2.0)
}
#[test]
fn pages_that_fit_are_listed_and_more_collapse_into_one_row_leaving_none_out() {
let s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Tall enough: each page under the last, the deeper page left out as it is in no list.
drawn(&mut shell, &s, TALL);
let tall = shell.page_places();
let pages: Vec<u8> = tall.iter().map(|p| p.0).collect();
assert_eq!(pages, (HOME..=PAGES).collect::<Vec<_>>());
assert!(tall.iter().all(|p| p.2 == Part::Tab));
assert!(tall.windows(2).all(|p| p[1].1[1] > p[0].1[1]));
assert_eq!(shell.strip_ends(), None);
// Short: the first page, then the section in one row, its first page between nothing and
// its next. Every place is inside the column, the row's on one line under the section's.
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let short = shell.page_places();
let parts: Vec<(u8, Part)> = short.iter().map(|p| (p.0, p.2)).collect();
assert_eq!(
parts,
[(HOME, Part::Tab), (1, Part::Shown), (2, Part::Next)]
);
assert_eq!(shell.strip_ends(), Some((false, true)));
let cols = Columns::of(size.0, size.1);
for (_, [x, _, w, _], _) in &short {
assert!(*x >= cols.side_x && x + w <= cols.side_x + style::CONTROL_W);
}
assert_eq!(short[1].1[1], short[2].1[1]);
assert_eq!(short[1].1[1], cols.page(2)[1]);
// The line naming the section stays over it.
let labelled = crate::widgets::last_labelled();
let line = labelled.iter().find(|w| w.label == SECTION).unwrap();
assert!(line.y > cols.page(1)[1] && line.y < cols.page(2)[1]);
}
#[test]
fn the_collapsed_rows_arrows_walk_every_page_and_its_ends_go_no_further() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
shell.show(1);
let mut seen = vec![1];
let size = loop {
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let places = shell.page_places();
let Some(&(next, rect, _)) = places.iter().find(|p| p.2 == Part::Next) else {
break size;
};
assert_eq!(
shell.click(&mut s, middle(rect), size, false, false, false),
[CLICK]
);
assert_eq!(shell.open_page(), Some(next));
seen.push(next);
};
assert_eq!(seen, (1..=PAGES).collect::<Vec<_>>());
assert_eq!(shell.strip_ends(), Some((true, false)));
// At the last page, the right of the row picks nothing: its arrow is dimmed.
let cols = Columns::of(size.0, size.1);
let [x, y, w, h] = cols.page(2);
assert!(
shell
.click(
&mut s,
(x + w - 2.0, y + h / 2.0),
size,
false,
false,
false
)
.is_empty()
);
assert_eq!(shell.open_page(), Some(PAGES));
// The page before it from the keyboard: Enter on its arrow opens it, and the focus follows
// it to the middle of the row.
let before = shell
.page_places()
.iter()
.find(|p| p.0 == PAGES - 1)
.unwrap()
.2;
assert_eq!(before, Part::Prev);
shell.set_focus(Some(Target::Page(PAGES - 1)));
assert_eq!(shell.key(&mut s, K::Enter, None, false, size), [CLICK]);
drawn(&mut shell, &s, SHORT_WINDOW);
let now = shell
.page_places()
.iter()
.find(|p| p.0 == PAGES - 1)
.unwrap()
.2;
assert_eq!(now, Part::Shown);
}
#[test]
fn while_searching_the_collapsed_rows_arrows_skip_pages_without_matches() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Rows 1, 10, 11 and 12: none before the first. The first page stays, as it has no match.
shell.set_search("row 1");
let size = drawn(&mut shell, &s, SHORT_WINDOW);
let pages = |shell: &Shell<Pages>| shell.page_places().iter().map(|p| p.0).collect::<Vec<_>>();
assert_eq!(pages(&shell), [HOME, 1, 10]);
assert_eq!(shell.strip_ends(), Some((false, true)));
// Its arrow goes to the next match, which has the first one before it and the last after.
let next = shell.page_places()[2].1;
shell.click(&mut s, middle(next), size, false, false, false);
drawn(&mut shell, &s, SHORT_WINDOW);
assert_eq!(pages(&shell), [HOME, 1, 10, 11]);
assert_eq!(shell.strip_ends(), Some((true, true)));
}
#[test]
fn a_controllers_bumpers_find_the_collapsed_rows_parts_as_tabs() {
let s = Settings::default();
let mut shell = Shell::<Pages>::default();
shell.show(5);
drawn(&mut shell, &s, SHORT_WINDOW);
let tabs: Vec<(f32, bool)> = crate::widgets::last_targets()
.into_iter()
.filter_map(|t| match t.kind {
crate::widgets::Kind::Tab(open) => Some((t.rect[0], open)),
_ => None,
})
.collect();
// The first page, the page before, the open one (lit) and the one after.
assert_eq!(tabs.len(), 4, "{tabs:?}");
assert_eq!(tabs.iter().filter(|t| t.1).count(), 1);
let (before, open, after) = (tabs[1].0, tabs[2], tabs[3].0);
assert!(open.1 && before < open.0 && open.0 < after);
// The arrows are vanilla's Previous Page and Next Page to the narrator.
let labels = crate::widgets::last_labelled();
let named = |key| {
labels
.iter()
.any(|w| w.label == lang::get(key) && w.enabled)
};
assert!(named(PREVIOUS_PAGE) && named(NEXT_PAGE));
}
#[test]
fn a_link_opens_a_page_out_of_the_list_and_escape_comes_back_to_the_links_page() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
// Found by the search from another page: back is the page the link is on.
shell.show(HOME);
shell.set_search("deeper");
let at = shell.point_at(&s, SIZE, LINK);
assert_eq!(shell.click(&mut s, at, SIZE, false, false, false), [CLICK]);
// The page opens alone, the search cleared; the list lights the page the link was on.
assert_eq!(shell.open_page(), Some(DEEP));
assert_eq!(shell.listed(&s, SIZE), [(DEEP, u32::from(DEEP))]);
drawn(&mut shell, &s, TALL);
assert_eq!(shell.lit_page(), Some(1));
assert!(shell.key(&mut s, K::Escape, None, false, SIZE).is_empty());
assert_eq!(shell.open_page(), Some(1));
assert_eq!(
shell.key(&mut s, K::Escape, None, false, SIZE),
[Output::Done]
);
}
#[test]
fn apply_shows_beside_undo_while_changes_wait_and_goes_with_them() {
let mut s = Settings::default();
let mut shell = Shell::<Pages>::default();
let done = Columns::of(SIZE.0, SIZE.1).button(0);
assert_eq!(shell.foot_at(&s, SIZE), [(Target::Done, done)]);
let at = shell.point_at(&s, SIZE, WAITS);
shell.click(&mut s, at, SIZE, false, false, false);
let foot = shell.foot_at(&s, SIZE);
let targets: Vec<_> = foot.iter().map(|f| f.0).collect();
assert_eq!(targets, [Target::Undo, Target::Apply, Target::Done]);
// Side by side over Done, as wide as it together.
let ([ux, uy, uw, _], [ax, ay, aw, _]) = (foot[0].1, foot[1].1);
assert_eq!((uy, ay), (foot[2].1[1] - FOOT_GAP - style::BUTTON_H, uy));
assert!(ux + uw < ax && ax + aw == done[0] + done[2] && ux == done[0]);
assert_eq!(
shell.click(&mut s, middle(foot[1].1), SIZE, false, false, false),
[CLICK, CHANGED]
);
assert_eq!(shell.screen.applied, 1);
let targets: Vec<_> = shell.foot_at(&s, SIZE).iter().map(|f| f.0).collect();
assert_eq!(targets, [Target::Undo, Target::Done]);
}
#[test]
fn the_right_button_steps_no_choice_as_vanillas_cycle_button() {
let mut s = Settings::default();
let mut shell = Shell::<Probe>::default();
let choice = middle_of(&mut shell, CHOICE, SIZE);
assert!(
shell
.click(&mut s, choice, SIZE, false, true, false)
.is_empty()
);
assert!(shell.screen.pressed.is_empty());
}
+7 -332
View File
@@ -932,56 +932,7 @@ pub struct Downloader {
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
/// How long a failed texture download waits before it is tried again.
retry_after: std::time::Duration,
/// The `states` keys, oldest first, so the oldest settled one can go ([`Bounds::states`]).
state_order: std::collections::VecDeque<(Kind, String)>,
/// The `resources` keys, oldest first ([`Bounds::resources`]).
resource_order: std::collections::VecDeque<String>,
/// Downloads started whose answer `state` has not taken yet: one thread each.
in_flight: usize,
/// Fetches a texture's body; the HTTP GET, but for a test.
fetch: fn(&str) -> Option<Vec<u8>>,
bounds: Bounds,
}
/// A download thread's answer to `state`. If the thread panics before giving it (a body that
/// breaks the decoder, say), dropping this answers "failed" instead: the download's place in
/// `in_flight` is given back and the texture is retried after the wait, where an answer that never
/// came kept the key `Pending` for good and, with the bound, the place taken for the session.
struct Answer {
send: std::sync::mpsc::Sender<((Kind, String), Option<Vec<u8>>)>,
key: Option<(Kind, String)>,
}
impl Answer {
fn give(&mut self, image: Option<Vec<u8>>) {
if let Some(key) = self.key.take() {
let _ = self.send.send((key, image));
}
}
}
impl Drop for Answer {
fn drop(&mut self) {
self.give(None);
}
}
/// What the downloader keeps and runs at most. Every texture URL a server (or a tab list of
/// hundreds of players) names used to add a `states` entry and a thread of its own, and every
/// `texture` id a `resources` entry, none of them ever dropped.
#[derive(Clone, Copy, Debug)]
struct Bounds {
/// Entries of `states`; the oldest settled (ready or failed) one is dropped past this. A
/// dropped texture is loaded again from the disk cache when it is asked for.
states: usize,
/// Entries of `resources`; the oldest is dropped past this, and read from the packs again.
resources: usize,
/// Downloads running at once. Past this `state` says `Pending` and starts nothing, so the
/// caller, which asks again every frame, starts it when one has finished.
downloads: usize,
}
const BOUNDS: Bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 16,
};
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
const MAX_SKIN_IMAGES: usize = 1024;
@@ -1013,11 +964,6 @@ impl Downloader {
resources: Default::default(),
failed: Default::default(),
retry_after: std::time::Duration::from_secs(60),
state_order: Default::default(),
resource_order: Default::default(),
in_flight: 0,
fetch: http_get,
bounds: BOUNDS,
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
@@ -1027,7 +973,6 @@ impl Downloader {
self.images.remove(&location);
self.image_order.retain(|l| *l != location);
}
self.resource_order.clear();
self.resource_root = root;
}
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
@@ -1057,47 +1002,12 @@ impl Downloader {
}
None => Download::Failed,
};
if self.resources.insert(location.to_owned(), state).is_none() {
self.resource_order.push_back(location.to_owned());
while self.resources.len() > self.bounds.resources
&& let Some(old) = self.resource_order.pop_front()
{
self.resources.remove(&old);
}
}
}
/// Forgets how `location` loaded, so `resource` reads it again.
fn forget_resource(&mut self, location: &str) {
if self.resources.remove(location).is_some() {
self.resource_order.retain(|l| l != location);
}
}
/// Records how `key` stands. A new key may push the oldest settled one out of `states`;
/// downloads running are never dropped, so their answer has a key to land on.
fn set_state(&mut self, key: (Kind, String), state: Download) {
if self.states.insert(key.clone(), state).is_some() {
return;
}
self.state_order.push_back(key);
while self.states.len() > self.bounds.states {
let Some(at) = self
.state_order
.iter()
.position(|k| self.states.get(k) != Some(&Download::Pending))
else {
break;
};
if let Some(old) = self.state_order.remove(at) {
self.states.remove(&old);
self.failed.remove(&old);
}
}
self.resources.insert(location.to_owned(), state);
}
/// How the download of `url` stands, starting it the first time. A
/// failure is permanent for `retry_after`, then tried again.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
self.in_flight = self.in_flight.saturating_sub(1);
let state = match image {
Some(rgba) => {
match key.0 {
@@ -1113,42 +1023,29 @@ impl Downloader {
Download::Failed
}
};
self.set_state(key, state);
self.states.insert(key, state);
}
let Some(hash) = texture_hash(url) else {
return Download::Failed;
};
let key = (kind, hash);
let full = self.in_flight >= self.bounds.downloads;
if let Some(state) = self.states.get(&key).copied() {
let due = self
.failed
.get(&key)
.is_none_or(|at| at.elapsed() >= self.retry_after);
// A failure that is due waits for a free download like a new texture does.
if state != Download::Failed || !due || full {
if state != Download::Failed || !due {
return state;
}
self.states.remove(&key);
self.failed.remove(&key);
self.state_order.retain(|k| *k != key);
}
if full {
// Not recorded: the next call asks again, and starts it once one has finished.
return Download::Pending;
}
self.in_flight += 1;
self.set_state(key.clone(), Download::Pending);
self.states.insert(key.clone(), Download::Pending);
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
let fetch = self.fetch;
std::thread::spawn(move || {
let mut answer = Answer {
send,
key: Some(key),
};
let image = Self::load_texture(&root, kind, &digest, &url, fetch);
answer.give(image);
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
let _ = send.send((key, image));
});
Download::Pending
}
@@ -1211,7 +1108,7 @@ impl Downloader {
if location.starts_with("patch/") {
let Some(image) = self.images.get(location).cloned() else {
// Dropped from memory: read from the packs again.
self.forget_resource(location);
self.resources.remove(location);
return false;
};
self.queue(location.to_owned(), image);
@@ -2028,225 +1925,3 @@ mod tests {
assert_eq!(served.load(Ordering::SeqCst), 2);
}
}
#[cfg(test)]
mod download_bound_tests {
use super::{Bounds, Download, Downloader, Kind};
use std::sync::atomic::{AtomicUsize, Ordering};
fn downloader(name: &str, bounds: Bounds, fetch: fn(&str) -> Option<Vec<u8>>) -> Downloader {
let root =
std::env::temp_dir().join(format!("horizon-test-skin-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut d = Downloader::new(root);
d.fetch = fetch;
d.bounds = bounds;
d
}
fn url(i: usize) -> String {
format!("https://textures.example/texture/skin{i:05}")
}
/// Asks again until the texture's download has settled.
fn wait(d: &mut Downloader, url: &str) -> Download {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let state = d.state(Kind::Skin, url);
if state != Download::Pending || std::time::Instant::now() > deadline {
return state;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
static RUNNING: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);
/// A fetch that is slow enough for the others to pile up behind it, and counts how many run.
fn slow_failure(_: &str) -> Option<Vec<u8>> {
let now = RUNNING.fetch_add(1, Ordering::SeqCst) + 1;
PEAK.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
RUNNING.fetch_sub(1, Ordering::SeqCst);
None
}
/// The same, not counted: tests run side by side, and the count is another test's.
fn sleepy_failure(_: &str) -> Option<Vec<u8>> {
std::thread::sleep(std::time::Duration::from_millis(20));
None
}
/// A tab list of 60 players names 60 textures at once: no more than the bound run together,
/// the ones that waited start as the others finish, and every one ends. Every frame asks again.
#[test]
fn downloads_run_a_few_at_a_time_and_the_late_ones_start_later() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 4,
};
let mut d = downloader("threads", bounds, slow_failure);
let urls: Vec<String> = (0..60).map(url).collect();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let mut first = true;
loop {
let states: Vec<Download> = urls.iter().map(|u| d.state(Kind::Skin, u)).collect();
if first {
// Asked all at once: four started, and the others were not even recorded.
assert_eq!(d.in_flight, 4);
assert_eq!(d.states.len(), 4);
assert!(states.iter().all(|s| *s == Download::Pending));
first = false;
}
if states.iter().all(|s| *s == Download::Failed) {
break;
}
assert!(
std::time::Instant::now() < deadline,
"{} still pending",
states.len()
);
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(
PEAK.load(Ordering::SeqCst) <= 4,
"{}",
PEAK.load(Ordering::SeqCst)
);
assert_eq!(d.states.len(), 60);
assert_eq!(d.in_flight, 0);
}
/// More textures than `states` holds: the map keeps to its bound, the oldest settled ones go
/// (and their failure time with them), and one that went is asked again as a new texture.
#[test]
fn the_states_keep_to_their_bound_and_drop_the_oldest() {
let bounds = Bounds {
states: 8,
resources: 4096,
downloads: 16,
};
let mut d = downloader("states", bounds, |_| None);
for i in 0..40 {
let u = url(i);
assert_eq!(wait(&mut d, &u), Download::Failed, "{i}");
assert!(d.states.len() <= 8 && d.state_order.len() == d.states.len());
assert!(d.failed.len() <= 8);
}
let kept: Vec<usize> = (0..40)
.filter(|i| {
d.states
.contains_key(&(Kind::Skin, super::texture_hash(&url(*i)).unwrap()))
})
.collect();
assert_eq!(kept, (32..40).collect::<Vec<_>>());
// The first one went: asked again, it starts again (and fails again).
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
assert_eq!(d.in_flight, 1);
}
/// A fetch that panics, as a decoder can on a body a server chose.
fn panicking(_: &str) -> Option<Vec<u8>> {
panic!("a fetch that panics, on purpose");
}
/// A thread that panics before it answers still gives its place back: with two places and six
/// textures whose fetch panics, every one settles as failed instead of the places staying
/// taken for good.
#[test]
fn a_download_that_panics_gives_its_place_back() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 2,
};
let mut d = downloader("panics", bounds, panicking);
for i in 0..6 {
assert_eq!(wait(&mut d, &url(i)), Download::Failed, "{i}");
}
assert_eq!(d.in_flight, 0);
assert_eq!(d.states.len(), 6);
}
/// A fetch long enough for a test to ask between its start and its end.
fn slowish_failure(_: &str) -> Option<Vec<u8>> {
std::thread::sleep(std::time::Duration::from_millis(150));
None
}
/// A failure whose wait is over is retried like a new texture: not while every place is taken
/// (it keeps answering `Failed`, and starts nothing), and as soon as one is free.
#[test]
fn a_failure_that_is_due_waits_for_a_free_place() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 1,
};
let mut d = downloader("due", bounds, slowish_failure);
let wait_time = d.retry_after;
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
// Another texture takes the only place; the first one's retry becomes due but has to wait.
assert_eq!(d.state(Kind::Skin, &url(1)), Download::Pending);
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Failed);
assert_eq!(d.in_flight, 1);
d.retry_after = wait_time;
assert_eq!(wait(&mut d, &url(1)), Download::Failed);
// The place is free: the retry starts.
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
assert_eq!(d.in_flight, 1);
}
/// A texture that is running is never the one dropped, however many settle behind it.
#[test]
fn a_running_download_keeps_its_place_in_the_states() {
let bounds = Bounds {
states: 2,
resources: 4096,
downloads: 16,
};
let mut d = downloader("running", bounds, sleepy_failure);
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
for i in 1..6 {
d.set_state((Kind::Cape, format!("settled{i}")), Download::Ready);
}
assert_eq!(
d.states
.get(&(Kind::Skin, super::texture_hash(&url(0)).unwrap())),
Some(&Download::Pending)
);
assert!(d.states.len() <= 3);
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
}
/// `resources` keeps to its bound too: a server's `texture` ids, one for each unique one, no
/// longer pile up, and one that was dropped is read from the packs again.
#[test]
fn the_resource_textures_keep_to_their_bound() {
let bounds = Bounds {
states: 4096,
resources: 8,
downloads: 16,
};
let mut d = downloader("resources", bounds, |_| None);
for i in 0..40 {
d.resource(&format!("patch/n/{i}"), "n", &format!("t{i}"));
assert!(d.resources.len() <= 8 && d.resource_order.len() == d.resources.len());
}
let kept: Vec<usize> = (0..40)
.filter(|i| d.resources.contains_key(&format!("patch/n/{i}")))
.collect();
assert_eq!(kept, (32..40).collect::<Vec<_>>());
// Forgetting one by hand (as `again` does) leaves no stale key behind.
d.forget_resource("patch/n/35");
assert_eq!(d.resource_order.len(), d.resources.len());
assert!(!d.resource_order.iter().any(|l| l == "patch/n/35"));
// New packs start the count over.
d.set_resource_root(std::env::temp_dir());
assert!(d.resources.is_empty() && d.resource_order.is_empty());
}
}
+19 -98
View File
@@ -127,9 +127,6 @@ pub struct Sprites {
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<StillKey, u32>,
/// Names a connected-texture rule matches by name (`matchTiles`, `connectTiles`): each keeps a
/// layer of its own ([`Sprites::keep_apart`]).
apart: std::collections::HashSet<String>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
@@ -221,13 +218,6 @@ impl Sprites {
..Self::default()
}
}
/// Keeps each of `names` on a layer of its own, never shared with an identical texture: the
/// connected-texture rules that name them find their faces by layer, so a texture sharing it
/// would be matched too (a tile a rule chains onto, and a model's transparent placeholder
/// with the same pixels). Before the textures that bear these names are inserted.
pub fn keep_apart(&mut self, names: impl IntoIterator<Item = String>) {
self.apart.extend(names);
}
/// Write what this run decoded to the cache file.
pub fn save_disk_cache(&mut self) {
if let Some(disk) = &mut self.disk {
@@ -297,13 +287,8 @@ impl Sprites {
let mut resized = Vec::with_capacity(self.native.len());
self.pixels.clear();
for (from, frames, initial) in &self.native {
// Native frames already match the atlas size: share the immutable
// buffers with animations rather than copying every frame again.
let frames: Arc<Vec<Vec<u8>>> = if *from == size {
Arc::clone(frames)
} else {
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect())
};
let frames: Arc<Vec<Vec<u8>>> =
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect());
self.pixels.extend_from_slice(&frames[*initial]);
resized.push(frames);
}
@@ -701,24 +686,23 @@ impl Sprites {
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still =
(animation.is_none() && frames.len() == 1 && !self.apart.contains(name)).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
self.layers.insert(name.to_owned(), same);
return Ok(same);
@@ -888,35 +872,6 @@ mod tests {
use super::*;
use std::fs::{self, File};
#[test]
fn native_frames_at_atlas_size_are_shared_without_resampling() {
let pixels = vec![
[10, 20, 30, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
[40, 50, 60, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
];
let native = Arc::new(pixels.clone());
let smaller = Arc::new(vec![vec![12; 8 * 8 * 4], vec![34; 8 * 8 * 4]]);
let metadata = r#"{"frametime":2}"#;
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::clone(&native), 1),
(8, Arc::clone(&smaller), 0),
],
pbr: vec![[None, None], [None, None]],
animations: vec![
test_frames(0, pixels.clone(), metadata),
test_frames(1, smaller.as_ref().clone(), metadata),
],
..Default::default()
};
sprites.finish();
assert!(Arc::ptr_eq(&sprites.animations[0].frames, &native));
assert!(!Arc::ptr_eq(&sprites.animations[1].frames, &smaller));
assert_eq!(sprites.animations[0].pixels(1), pixels[1].as_slice());
let resized = resample(&smaller[0], 8, SPRITE_SIZE);
assert_eq!(sprites.animations[1].pixels(0), resized.as_slice());
assert_eq!(&sprites.pixels[..pixels[1].len()], pixels[1].as_slice());
}
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
@@ -1327,40 +1282,6 @@ mod tests {
assert_eq!(sprites.pixels.len(), 3 * 16 * 16 * 4);
}
#[test]
fn a_texture_a_connected_rule_matches_keeps_a_layer_of_its_own() {
// A resource pack's transparent placeholders: a model's layer and a tile another
// connected-texture rule chains onto (`matchTiles=./19.png`) have the same pixels. The
// rule finds its faces by layer, so the one it names can't share the others'.
let root =
std::env::temp_dir().join(format!("horizon-sprite-apart-{}", std::process::id()));
let directory = root.join("assets/a/textures/block");
fs::create_dir_all(&directory).unwrap();
let write = |name: &str| {
let mut encoder =
png::Encoder::new(File::create(directory.join(name)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[0; 16 * 16 * 4])
.unwrap();
};
for name in ["placeholder.png", "named.png", "again.png"] {
write(name);
}
let mut sprites = Sprites::default();
sprites.keep_apart(["a:block/named".to_owned()]);
let placeholder = sprites.insert(&root, "a:block/placeholder").unwrap();
let named = sprites.insert(&root, "a:block/named").unwrap();
let again = sprites.insert(&root, "a:block/again").unwrap();
fs::remove_dir_all(&root).unwrap();
// The named one alone; the others still share.
assert_ne!(named, placeholder);
assert_eq!(again, placeholder);
assert_eq!(sprites.count(), 2);
}
#[test]
fn a_32px_only_atlas_does_not_add_an_extra_mipmap() {
let mut sprites = Sprites {
native: vec![
+22 -26
View File
@@ -605,9 +605,6 @@ pub struct Streaming {
/// Set while `update` runs, after it refreshed `cover`: nothing else can
/// change `extra` meanwhile, so `cover` answers without comparing them.
cover_current: bool,
/// `forced` and `mod_forced` as each column's distance to the nearest one, made again when
/// they change; read like `cover`, while `cover_current`.
forced_cover: Option<ForcedCover>,
/// Mesh the sections for drawing (false on a server thread).
pub mesh: bool,
/// The pregeneration band (`set_band`).
@@ -809,7 +806,6 @@ impl GenerationScan {
forced: input.ranges.forced,
mod_forced: input.ranges.mod_forced,
cover: input.ranges.cover,
forced_cover: None,
band: input.ranges.band,
};
self.remember(ranges, input.world);
@@ -1070,10 +1066,8 @@ pub struct WorkerContext {
pub light: Arc<LightTables>,
}
mod bounded;
mod forced_cover;
mod relight_now;
use bounded::nearest_first;
use forced_cover::ForcedCover;
impl Streaming {
pub fn new<G: ChunkGenerator>(
generator: Arc<G>,
@@ -1182,7 +1176,6 @@ impl Streaming {
extra: Vec::new(),
cover: None,
cover_current: false,
forced_cover: None,
mesh: true,
band: None,
band_ranked: None,
@@ -1698,7 +1691,6 @@ impl Streaming {
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
columns.clear();
ExtraCover::refresh(&mut self.cover, &self.extra);
ForcedCover::refresh(&mut self.forced_cover, &self.forced, &self.mod_forced);
self.cover_current = true;
// The sweeps below walk every loaded column and proto chunk. Run them
// at once when the view moves and every eighth tick otherwise: a
@@ -1878,7 +1870,6 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
};
let (protos, generating) = (&self.protos, &self.generating);
@@ -2049,7 +2040,6 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
};
self.waiting.retain(|&(x, sy, z)| {
@@ -2155,7 +2145,6 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
},
world,
@@ -2294,7 +2283,6 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
}
}
@@ -2575,8 +2563,6 @@ struct Ranges<'a> {
/// `extra` indexed by a grid or buckets: coverage queries without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
/// `forced` and `mod_forced` by distance (`Streaming::forced_cover`), while they match it.
forced_cover: Option<&'a ForcedCover>,
band: Option<&'a Band>,
}
/// The chunks a pregeneration task (`/pregen`) wants lit now: rows `z0..=z1`
@@ -2625,7 +2611,6 @@ impl Band {
+ i64::from(x - self.x0 + 8).max(0)
}
}
/// For every column near the extra areas, how far it lies outside the
/// nearest one's square (Chebyshev distance less its radius; 0 inside). A server has an area per player, and streaming asks of every
/// loaded and wanted column whether one reaches it, several times a tick:
@@ -2933,6 +2918,16 @@ impl Ranges<'_> {
.iter()
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
}
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// The columns meshed for drawing: those vanilla draws around the
/// player (`in_view_distance`), and forced ones.
fn in_view(&self, x: i32, z: i32) -> bool {
@@ -2944,7 +2939,18 @@ impl Ranges<'_> {
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
return true;
}
self.near_forced(x, z, margin)
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
impl Drop for Streaming {
@@ -3478,7 +3484,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let generate = |x, z, priority, order| Queued {
@@ -3719,7 +3724,6 @@ mod tests {
forced: &self.forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
},
world: &self.world,
@@ -4103,7 +4107,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let square = |x: i32, z: i32, n: i32| {
@@ -4161,7 +4164,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let span = radius * 3 + 20;
@@ -4199,7 +4201,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
for x in -5i32..=15 {
@@ -4234,7 +4235,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + ((x - 40).abs().max((z + 9).abs()) - view).max(0);
@@ -4290,7 +4290,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
@@ -4431,7 +4430,6 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
@@ -4475,7 +4473,6 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
let (scan, buckets) = (ranges(None), ranges(Some(&cover)));
@@ -4542,7 +4539,6 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
// The columns streaming asks about: everything kept, and the
@@ -1,165 +0,0 @@
//! The chunks a `/forceload` or a native mod's ticket holds, as `Ranges` reads them, and their
//! distance map.
use super::{FORCED_FEATURES_RING, FORCED_FULL_RING, FORCED_TERRAIN_RING, Ranges};
use crate::fast_hash::FxHashMap;
use std::collections::HashSet;
/// `Streaming::forced` and `mod_forced` as the Chebyshev distance from each column within
/// `REACH` of one to the nearest: `Ranges::in_main_range` answers by one lookup instead of going
/// through every forced chunk (it is asked for every column, several times a tick).
pub(super) struct ForcedCover {
forced: HashSet<(i32, i32)>,
mod_forced: HashSet<(i32, i32)>,
near: FxHashMap<(i32, i32), u8>,
}
impl ForcedCover {
/// The widest ring `in_main_range` asks of a forced chunk (margins 1 to 4).
const REACH: i32 = FORCED_TERRAIN_RING + 1;
/// Past this many forced chunks the cover is not made: the scan answers.
const MOST: usize = 4096;
pub(super) fn refresh(
cover: &mut Option<Self>,
forced: &HashSet<(i32, i32)>,
mod_forced: &HashSet<(i32, i32)>,
) {
if cover
.as_ref()
.is_some_and(|c| c.forced == *forced && c.mod_forced == *mod_forced)
{
return;
}
if forced.len() + mod_forced.len() > Self::MOST {
*cover = None;
return;
}
let mut near = FxHashMap::default();
for &(fx, fz) in forced.iter().chain(mod_forced) {
for dx in -Self::REACH..=Self::REACH {
for dz in -Self::REACH..=Self::REACH {
let d = dx.abs().max(dz.abs()) as u8;
near.entry((fx + dx, fz + dz))
.and_modify(|e: &mut u8| *e = (*e).min(d))
.or_insert(d);
}
}
}
*cover = Some(Self {
forced: forced.clone(),
mod_forced: mod_forced.clone(),
near,
});
}
/// Whether a forced chunk lies within `reach` of `(x, z)`, or `None` past `REACH`.
pub(super) fn within(&self, x: i32, z: i32, reach: i32) -> Option<bool> {
(reach <= Self::REACH).then(|| {
self.near
.get(&(x, z))
.is_some_and(|&d| i32::from(d) <= reach)
})
}
}
impl Ranges<'_> {
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
pub(super) fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
pub(super) fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// `in_main_range` past the player's circle: whether a forced chunk lies within the ring
/// `margin` asks for.
pub(super) fn near_forced(&self, x: i32, z: i32, margin: i32) -> bool {
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
if let Some(hit) = self.forced_cover.and_then(|c| c.within(x, z, reach)) {
return hit;
}
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chunks(seed: &mut u64, n: usize, spread: i32) -> HashSet<(i32, i32)> {
let mut next = |m: i32| {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
(*seed % (2 * m as u64 + 1)) as i32 - m
};
(0..n).map(|_| (next(spread), next(spread))).collect()
}
/// With the forced chunks by distance, every column gets the answer the scan of every forced
/// chunk gives, at every margin streaming asks (and those past the map's reach), after the
/// forced chunks change too.
#[test]
fn the_distances_answer_as_the_scan() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut cover = None;
for case in 0..24 {
let spread = [3, 12, 40][case % 3];
let forced = chunks(&mut seed, 1 + case % 9, spread);
// Some mod tickets on forced chunks, some apart.
let mut mod_forced = chunks(&mut seed, case % 4, spread);
mod_forced.extend(forced.iter().take(case % 2));
ForcedCover::refresh(&mut cover, &forced, &mod_forced);
let made = cover.as_ref().expect("a few forced chunks get a map");
let ranges = |forced_cover| Ranges {
// The player's circle far away: only the forced chunks answer.
center: (10_000, 10_000),
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &mod_forced,
cover: None,
forced_cover,
band: None,
};
let (scan, map) = (ranges(None), ranges(Some(made)));
let span = spread + 10;
for z in -span..=span {
for x in -span..=span {
for margin in 0..=7 {
assert_eq!(
map.in_main_range(x, z, margin),
scan.in_main_range(x, z, margin),
"case {case} ({x}, {z}) margin {margin}"
);
}
for d in [false, true] {
assert_eq!(map.requested(x, z, d), scan.requested(x, z, d));
assert_eq!(map.chunk_kept(x, z, d), scan.chunk_kept(x, z, d));
assert_eq!(map.unloaded(x, z, d), scan.unloaded(x, z, d));
}
}
}
}
}
/// Too many forced chunks for a map: none is made, and the scan answers.
#[test]
fn past_the_most_no_map_is_made() {
let forced: HashSet<(i32, i32)> = (0..=ForcedCover::MOST as i32).map(|x| (x, 0)).collect();
let mut cover = None;
ForcedCover::refresh(&mut cover, &HashSet::new(), &HashSet::from([(0, 0)]));
assert!(cover.is_some());
ForcedCover::refresh(&mut cover, &forced, &HashSet::new());
assert!(cover.is_none());
}
}
+1 -11
View File
@@ -524,18 +524,8 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let packed_light = ((light & 63u) << 2u) | (((light >> 6u) & 63u) << 10u);
let g = (lo.y >> (8u * corner)) & 255u;
let t = lo.x;
// Corners 1 to 3's own alpha (`packed::ALPHA_STEP` times the code; 0: word 0's).
var code = 0u;
if corner == 1u {
code = (hi.x >> 28u) & 7u;
} else if corner == 2u {
code = (hi.y >> 24u) & 7u;
} else if corner == 3u {
code = (hi.y >> 27u) & 7u;
}
let alpha = select((code * 51u) << 24u, t & 0xff000000u, code == 0u);
let color = (g * ((t >> 16u) & 255u) / 255u) | ((g * ((t >> 8u) & 255u) / 255u) << 8u)
| ((g * (t & 255u) / 255u) << 16u) | alpha;
| ((g * (t & 255u) / 255u) << 16u) | (t & 0xff000000u);
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
}
@@ -11,18 +11,6 @@
"horizon.options.gameplay_guide.basic": "Basic",
"horizon.options.gameplay_guide.full": "Full",
"horizon.options.gameplay_guide.off": "Off",
"horizon.options.radial_mode": "Radial Menu",
"horizon.options.radial_mode.about": "Hold the opener for the radial menu, or press it once to open it and again to close it.",
"horizon.options.radial_mode.hold.about": "Hold the opener and let go to run what the stick points at.",
"horizon.options.radial_mode.toggle.about": "Press the opener to open it; A or the opener again runs what the stick points at, B closes it.",
"horizon.options.radial_stick": "Radial Menu Selects With",
"horizon.options.radial_stick.about": "The stick that points at the radial menu's slots.",
"horizon.options.radial_stick.right": "Right Stick",
"horizon.options.radial_stick.left": "Left Stick",
"horizon.options.radial_stick.either": "Either",
"horizon.options.radial_stick.right.about": "The right stick points at the slots; the left still walks.",
"horizon.options.radial_stick.left.about": "The left stick points and you stand still. D-pad Right is under that thumb: pick an opener on the Radial Menu page.",
"horizon.options.radial_stick.either.about": "The last stick pushed points; neither walks nor turns the view. Only a bumper, a trigger or View can open the menu.",
"horizon.controller.guide.use": "Use",
"horizon.controller.guide.open": "Open",
"horizon.controller.guide.ride": "Ride",
@@ -141,82 +129,6 @@
"horizon.pages.shader_options": "Shader Options",
"horizon.pages.controller": "Controller Settings",
"horizon.pages.controller_binds": "Controller Binds",
"horizon.pages.controller_radial": "Radial Menu",
"horizon.radial.page.general": "General",
"horizon.radial.page.camera": "Camera",
"horizon.radial.release_stick": "Let go of the stick",
"horizon.radial.action.key_inventory": "Inventory",
"horizon.radial.action.key_chat": "Chat",
"horizon.radial.action.key_command": "Commands",
"horizon.radial.action.key_advancements": "Advancements",
"horizon.radial.action.key_togglePerspective": "Perspective",
"horizon.radial.action.key_swapOffhand": "Swap Hands",
"horizon.radial.action.key_drop": "Drop",
"horizon.radial.action.key_pickItem": "Pick Block",
"horizon.radial.action.key_screenshot": "Screenshot",
"horizon.radial.action.key_toggleGui": "Hide HUD",
"horizon.radial.action.key_smoothCamera": "Cinematic Camera",
"horizon.radial.action.key_socialInteractions": "Social",
"horizon.radial.action.key_emote": "Emote",
"horizon.radial.action.pad_hotbarPrevious": "Previous Slot",
"horizon.radial.action.pad_hotbarNext": "Next Slot",
"horizon.radial.action.key_fullscreen": "Fullscreen",
"horizon.radial.action.key_quickActions": "Quick Actions",
"horizon.radial.action.key_debug_overlay": "Debug Screen",
"horizon.radial.missing": "Missing",
"horizon.radial.why.missing": "Unknown action",
"horizon.radial.why.cosmetics": "Needs cosmetics on",
"horizon.radial.why.quick_actions": "This server has no Quick Actions",
"horizon.radial.why.social": "Only on a server",
"horizon.radial.next": "→ %s",
"horizon.radial.state.first_person": "First Person",
"horizon.radial.state.third_person_back": "Third Person",
"horizon.radial.state.third_person_front": "Front View",
"horizon.radial.state.shown": "Shown",
"horizon.radial.state.hidden": "Hidden",
"horizon.radial.state.on": "On",
"horizon.radial.state.off": "Off",
"horizon.radial.pick_title": "Choose an Action",
"horizon.radial.group.actions": "Actions",
"horizon.radial.pick.slot": "%s slot of %s",
"horizon.radial.pick.about": "Puts this action in the slot and goes back. The slot's own is marked.",
"horizon.radial.pick.current": "Current",
"horizon.radial.missing.about": "This game doesn't know the action %s: the slot keeps it and runs nothing. Press or Right to put another here.",
"horizon.radial.page.new": "New Page",
"horizon.pages.open.controller_radial": "Radial Menu...",
"horizon.radial.group.slots": "Slots",
"horizon.radial.group.page": "Page",
"horizon.radial.group.all": "All Pages",
"horizon.radial.empty": "Empty",
"horizon.radial.page_of": "Page %s of %s. Each slot is a direction of the selecting stick while the opener is held.",
"horizon.radial.slot.0": "Up",
"horizon.radial.slot.1": "Up-Right",
"horizon.radial.slot.2": "Right",
"horizon.radial.slot.3": "Down-Right",
"horizon.radial.slot.4": "Down",
"horizon.radial.slot.5": "Down-Left",
"horizon.radial.slot.6": "Left",
"horizon.radial.slot.7": "Up-Left",
"horizon.radial.slot.about": "The action this direction runs. Press it for the list of actions, Right for the next one, Left or Shift-click for the one before.",
"horizon.radial.rename": "Rename Page",
"horizon.radial.rename.about": "Give this page a name of your own.",
"horizon.radial.name_title": "Name This Page",
"horizon.radial.new_page.about": "Adds an empty page after the last one, and shows it.",
"horizon.radial.new_page.full": "The menu holds at most %s pages.",
"horizon.radial.delete_page": "Delete Page",
"horizon.radial.delete_page.about": "Removes this page and its slots at once. Undo brings it back.",
"horizon.radial.delete_page.last": "The menu keeps at least one page.",
"horizon.radial.reset": "Reset All Pages",
"horizon.radial.reset.about": "Puts back the General and Camera pages and removes the rest, at once. Undo brings them back.",
"horizon.radial.reset.default": "The pages are the defaults already.",
"horizon.radial.opener": "Opener",
"horizon.radial.opener.about": "The button held to open the menu. Press, then press the controller button to use: a bumper or the D-pad, not A, B, X, Y or the right stick, which steer and cancel.",
"horizon.radial.opener.about.left": "The button held to open the menu. Press, then press the controller button to use: a bumper, A, X or Y, not the D-pad or the left stick, which steer, nor B, which cancels.",
"horizon.radial.opener.about.either": "The button held to open the menu. Press, then press the controller button to use: a bumper, a trigger or View; both thumbs steer, and B cancels.",
"horizon.radial.unbound": "No button opens the menu (one under the thumb that selects is let go): press, then press a controller button.",
"horizon.radial.conflict": "Shared with another action: in play the menu takes that button. Press to pick another.",
"Radial Menu": "Radial Menu",
"Named pages of eight actions, held open with a button.": "Named pages of eight actions, held open with a button.",
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.studio": "Replay Studio",
@@ -530,7 +442,7 @@
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Auto uses the peak Windows reports. Calibrate if highlights look clipped.": "Auto uses the peak Windows reports. Calibrate if highlights look clipped.",
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
@@ -1108,7 +1020,6 @@
"Press the button for this. %s cancels.": "Press the button for this. %s cancels.",
"This button also does: %s": "This button also does: %s",
"Not bound: press this row, then the button that should do it.": "Not bound: press this row, then the button that should do it.",
"horizon.pad.action.radial": "Radial Menu",
"horizon.pad.notice.connected.press": "Press to use it",
"horizon.recipe_transfer.missing": "Missing Items",
"horizon.recipe_transfer.no_room": "Inventory full",
@@ -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]")?;
+5 -5
View File
@@ -1989,13 +1989,13 @@ mod tests {
}
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
/// with HORIZON_TEST_RESOURCES set to the prepared resources.
/// with HORIZON_RESOURCES set to the prepared resources.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
#[ignore = "needs the prepared resources"]
fn index_on_vanilla_tree() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let Some(root) = std::env::var_os("HORIZON_RESOURCES").map(std::path::PathBuf::from) else {
return;
};
let data = root.join("data");
let t = std::time::Instant::now();
let tree = Tree::load(&data);
+15 -13
View File
@@ -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, 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,
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;
@@ -89,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;
+8 -39
View File
@@ -26,9 +26,8 @@ pub struct Tab {
/// The page of tabs it is on: vanilla's are 0, mods' after, as NeoForge
/// pages them.
pub page: u32,
/// A Polytone creative tab modifier's `name`: its text (a translation key when
/// the flag is set) and colour, over the tab's own.
pub name: Option<(String, Option<u32>, bool)>,
/// A Polytone creative tab modifier's `name`: its text and colour, over the tab's own.
pub name: Option<(String, Option<u32>)>,
}
impl Tab {
/// The tab without its stacks.
@@ -104,28 +103,25 @@ pub struct Creative {
/// The packs' Polytone creative tab modifiers, applied by `rebuild`.
pack: Vec<crate::polytone_creative_tabs::TabModifier>,
}
/// A text component's text, its colour (a name, or `#rrggbb`) and whether the
/// text is a `translate` key, to be resolved when drawn.
fn component_text(json: &Value) -> (String, Option<u32>, bool) {
/// A text component's plain text and its colour (a name, or `#rrggbb`).
fn component_text(json: &Value) -> (String, Option<u32>) {
match json {
Value::String(s) => (s.clone(), None, false),
Value::String(s) => (s.clone(), None),
Value::Object(o) => {
let translate = o.get("translate").and_then(Value::as_str);
let text = o
.get("text")
.or_else(|| o.get("translate"))
.and_then(Value::as_str)
.or(translate)
.unwrap_or("")
.to_owned();
let translate = o.get("text").is_none() && translate.is_some();
let color = o.get("color").and_then(Value::as_str).and_then(|c| {
c.strip_prefix('#')
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.or_else(|| crate::text::named_color(c))
});
(text, color, translate)
(text, color)
}
_ => (String::new(), None, false),
_ => (String::new(), None),
}
}
/// The grid's stacks for one tab, search text and language.
@@ -916,16 +912,6 @@ impl Creative {
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
(x, y)
}
/// `CreativeModeInventoryScreen.checkTabHovering`: the tab whose icon
/// rectangle, 3 pixels in from its 26x32 slot (`isHovering(x + 3, y + 3,
/// 21, 27)`), holds the cursor. The screen draws its display name.
pub fn hovered_tab(tabs: &[Tab], origin: (f32, f32), cursor: (f32, f32)) -> Option<usize> {
tabs.iter().position(|tab| {
let (x, y) = Self::tab_position(tab);
let (x, y) = (origin.0 + x + 3.0, origin.1 + y + 3.0);
cursor.0 >= x && cursor.0 < x + 21.0 && cursor.1 >= y && cursor.1 < y + 27.0
})
}
/// `CreativeModeInventoryScreen.extractTabButton`'s cursor: an unselected
/// tab asks for the pointing hand when the whole-pixel mouse, relative to
/// the screen origin, is strictly inside its 26x32 button.
@@ -1803,21 +1789,4 @@ mod tests {
assert!(c.tab_headers().is_empty());
assert_eq!(c.search_tab(), None);
}
/// `CreativeModeInventoryScreen.checkTabHovering`: the 3-pixel inset of a
/// tab's 26x32 slot (21x27) is the hover area whose name the screen shows.
#[test]
fn only_a_tabs_inset_icon_is_hovered() {
let screen = tooltip_tabs();
let tab = &screen.tabs[0];
let (x, y) = Creative::tab_position(tab);
let at =
|dx: f32, dy: f32| Creative::hovered_tab(&screen.tabs, (0.0, 0.0), (x + dx, y + dy));
assert_eq!(at(4.0, 4.0), Some(0));
assert_eq!(at(23.5, 4.0), Some(0));
assert_eq!(at(2.0, 4.0), None);
assert_eq!(at(24.0, 4.0), None);
assert_eq!(at(4.0, 2.0), None);
assert_eq!(at(4.0, 30.0), None);
}
}
+11 -105
View File
@@ -8,51 +8,28 @@ use crate::{
text::{Style, Text},
};
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the last style `wanted`
/// under the cursor, in the same visual order and advances as rendering.
/// A drawn glyph is hit where it is drawn (`BakedSheetGlyph.GlyphInstance`),
/// even far below the line when its font's `ascent` moves it; an empty one
/// covers the line's 9 pixels (`EmptyArea`).
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the style under a drawn
/// message glyph, using the same visual order and advances as rendering.
pub fn line_style_at(
font: &Font,
text: &Text,
x: f32,
y: f32,
cursor: (f32, f32),
wanted: impl Fn(&Style) -> bool,
) -> Option<Style> {
let mut left = cursor.0 - x;
if left < 0.0 || !(0.0..9.0).contains(&(cursor.1 - y)) {
return None;
}
let text = text.resolve_legacy();
let visual = crate::bidi::visual(&text);
let mut pen = x;
let mut found = None;
for (c, style) in visual.chars() {
let advance = font.advance(c, style);
let object = c == '\u{fffc}' && style.inline_object.is_some();
let glyph = (!object).then(|| font.glyph_styled(c, style));
let (left, top, bottom) = match glyph {
Some(g) if g.rect.is_some() => {
let thick = if style.is_bold() { 0.1 } else { 0.0 };
let shear = if style.is_italic() {
1.0 - 0.25 * g.bottom
} else {
0.0
};
// Dialog text is drawn with a shadow, which extends the bottom.
(
pen + g.left + shear - thick,
y + g.top - thick,
y + g.bottom + g.offset + thick,
)
}
_ => (pen, y, y + 9.0),
};
let inside = (left..pen + advance).contains(&cursor.0) && (top..bottom).contains(&cursor.1);
if inside && wanted(style) {
found = Some(style.clone());
left -= font.advance(c, style);
if left < 0.0 {
return Some(style.clone());
}
pen += advance;
}
found
None
}
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
@@ -508,7 +485,7 @@ mod tests {
]),
&mut |_| Text::empty(),
);
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y), |_| true);
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y));
assert!(style(9.9, 20.0).is_none());
assert_eq!(style(17.9, 20.0).unwrap().click, None);
for x in [18.0, 21.0, 25.9] {
@@ -526,77 +503,6 @@ mod tests {
assert!(style(21.0, 29.0).is_none());
}
#[test]
fn a_glyph_moved_down_by_its_ascent_is_clicked_where_it_is_drawn() {
use crate::{component, hud::Rect, text::ClickEvent};
let root =
std::env::temp_dir().join(format!("horizon-dialog-ascent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut sheet = Vec::new();
{
let mut encoder = png::Encoder::new(&mut sheet, 8, 8);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[255; 8 * 8 * 4])
.unwrap();
}
for (path, bytes) in [
(
"assets/minecraft/font/low.json",
br#"{"providers":[{"type":"bitmap","file":"minecraft:font/box.png","height":8,"ascent":-28,"chars":["a"]}]}"#
.as_slice(),
),
("assets/minecraft/textures/font/box.png", sheet.as_slice()),
] {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
let font = Font::load(
&root,
&std::collections::HashMap::new(),
&[],
(4096, 1024),
(512, 512),
Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0,
v1: 0.0,
w: 1,
h: 1,
},
);
let _ = std::fs::remove_dir_all(&root);
let text = component::to_text(
&serde_json::json!([
{"text": "a", "font": "minecraft:low"},
{"text": "a", "font": "minecraft:low", "click_event": {
"action": "custom", "id": "test:requests"
}},
]),
&mut |_| Text::empty(),
);
let click = |x, y| {
line_style_at(&font, &text, 10.0, 20.0, (x, y), |s| s.click.is_some())
.and_then(|s| s.click)
};
let requests = Some(ClickEvent::Custom {
id: "test:requests".into(),
payload: None,
});
for (x, y) in [(19.0, 55.0), (22.0, 60.0), (27.9, 63.9)] {
assert_eq!(click(x, y), requests, "drawn 35 below the line at {x}, {y}");
}
assert_eq!(click(22.0, 24.0), None, "the line's own row holds nothing");
assert_eq!(click(22.0, 54.9), None, "above the glyph");
assert_eq!(click(22.0, 65.0), None, "below the glyph and its shadow");
assert_eq!(click(15.0, 60.0), None, "the plain glyph left of it");
}
#[test]
fn grids_pack_full_rows_and_centre_the_rest() {
// AuthMe's two buttons: one row, 2 apart.
+37 -57
View File
@@ -1005,7 +1005,7 @@ pub fn mob_to_nbt(
);
}
if id == "minecraft:piglin" {
crate::mobs::piglin::write_guarded_anger(m, map);
write_guarded_piglin_anger(m, map);
// InventoryCarrier.writeInventoryToTag.
map.insert(
"Inventory".into(),
@@ -2232,9 +2232,6 @@ pub fn mob_from_nbt_with_random(
) {
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
if m.species() == crate::mobs::Species::Piglin {
crate::mobs::piglin::read_guarded_anger(&mut m, tag);
}
if m.species() == crate::mobs::Species::Hoglin {
m.extra.cannot_be_hunted = flag(tag, "CannotBeHunted");
}
@@ -5972,6 +5969,42 @@ mod tests {
}
}
/// `Brain.Packed`: the guarded-block live ANGRY_AT entry. The existing
/// anger counter supplies its remaining long TTL, not an absolute timestamp.
fn write_guarded_piglin_anger(m: &Mob, map: &mut Compound) {
let Some(actor) = m.extra.piglin.guarded_angry_at else {
return;
};
let brain = map
.entry("Brain".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
if !matches!(brain, Tag::Compound(_)) {
*brain = Tag::Compound(Compound::new());
}
let Tag::Compound(brain) = brain else { return };
let memories = brain
.entry("memories".into())
.or_insert_with(|| Tag::Compound(Compound::new()));
if !matches!(memories, Tag::Compound(_)) {
*memories = Tag::Compound(Compound::new());
}
let Tag::Compound(memories) = memories else {
return;
};
if let Some(uuid) = actor.filter(|_| m.extra.anger > 0) {
// ExpirableValue.codec: the UUID value and optional remaining Long TTL.
memories.insert(
"minecraft:angry_at".into(),
Tag::compound([
("value", Tag::IntArray(uuid.to_vec())),
("ttl", Tag::Long(i64::from(m.extra.anger))),
]),
);
} else {
memories.remove(&"minecraft:angry_at".into());
}
}
/// `Brain`'s two measured Warden Unit memories, using actual existing slots.
fn write_warden_owned_memories(m: &Mob, map: &mut Compound) {
let brain = map
@@ -9342,59 +9375,6 @@ mod piglin_live_anger_memory_tests {
}
}
/// `Piglin.readAdditionalSaveData` reads the brain's `ANGRY_AT` memory
/// back: the actor's UUID and the ticks left (`ExpirableValue`'s TTL).
/// Horizon wrote the memory but never read it, so a piglin that a player
/// had angered came back calm after its chunk reloaded.
#[test]
fn a_saved_guarded_anger_loads_its_uuid_and_ticks() {
// A whole saved piglin: its id, a position and the brain's memory.
let saved = |memory: Tag| {
Tag::compound([
("id", Tag::String("minecraft:piglin".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
(
"Brain",
Tag::compound([("memories", Tag::compound([("minecraft:angry_at", memory)]))]),
),
])
};
let template = mob();
let types = [(template.kind.id.clone(), template.kind.clone())].into();
let uuid = Tag::IntArray(vec![90, 91, 92, 93]);
let ticking = saved(Tag::compound([
("value", uuid.clone()),
("ttl", Tag::Long(99)),
]));
let loaded = mob_from_nbt(&ticking, &types, 2, None).unwrap();
assert_eq!(loaded.extra.anger, 99);
assert_eq!(
loaded.extra.piglin.guarded_angry_at,
Some(Some([90, 91, 92, 93]))
);
// `ExpirableValue` leaves the TTL out when it never expires, and a
// long past the counter's range holds as long as it can: the counter
// keeps the longest it can while the goal ticks it down.
for memory in [
Tag::compound([("value", uuid.clone())]),
Tag::compound([("value", uuid.clone()), ("ttl", Tag::Long(1 << 40))]),
] {
let loaded = mob_from_nbt(&saved(memory), &types, 3, None).unwrap();
assert_eq!(loaded.extra.anger, i32::MAX);
assert_eq!(
loaded.extra.piglin.guarded_angry_at,
Some(Some([90, 91, 92, 93]))
);
}
}
#[test]
fn expired_owned_memory_cannot_resurrect_stale_raw_anger() {
let mut m = mob();
+1
View File
@@ -16,6 +16,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
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
+1 -2
View File
@@ -166,8 +166,7 @@ impl FurnaceCooking for Furnace {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
// Vanilla Mth.clamp is min(max(v, 0), max): saturates at a negative total instead of panicking.
self.cooking_timer = (self.cooking_timer - 2).max(0).min(self.cooking_total);
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
+19 -18
View File
@@ -1524,14 +1524,14 @@ impl LevelAccess for GameLevel<'_> {
fn block_entity_signal(
&self,
pos: BlockPos,
state: BlockStateId,
_state: BlockStateId,
_direction: Direction,
) -> i32 {
// TestBlock.ownSignal: the block state's mode gates the entity.
// TestBlock.ownSignal.
if let Some(BlockEntity::TestBlock(b)) =
self.world.block_entities.get(&(pos.x, pos.y, pos.z))
{
return crate::game::test_blocks::own_signal(self.registry, state, b.powered);
return b.own_signal();
}
// TrappedChestBlock.getSignal: the players with it open, at most 15.
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
@@ -2873,9 +2873,9 @@ struct Game<'a> {
water_plane_memo: std::cell::Cell<Option<f32>>,
/// The camera's block and the second `water_plane` last scanned at.
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
/// What the boats' and minecarts' ticks know about the mobs: their box reach
/// (`vehicle_mob_reach`) and a grid of their boxes (`mobs_meeting`).
vehicle_mobs: boat_passengers::VehicleMobs,
/// The mobs' box reach while the boats and minecarts tick, with the mob count it was
/// worked out for (`vehicle_mob_reach`).
vehicle_mob_reach: std::cell::Cell<Option<(usize, horizon_client::mobs::BoxReach)>>,
persist: persist::EntityMemory,
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
@@ -5832,7 +5832,7 @@ impl Game<'_> {
self.draw_debug_overlay(&mut batch, size);
self.frame_graph
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
self.draw_pad_over_hud(&mut batch);
self.draw_pad_cursor(&mut batch);
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
@@ -5963,7 +5963,6 @@ impl Game<'_> {
if self.camera_type.first_person()
&& (!spectator || self.spectator_crosshair())
&& !self.debug_crosshair()
&& !self.overlay_holds_view()
{
let attack = (indicator == 1).then_some(strength);
let full = attack.is_some_and(|a| {
@@ -6093,7 +6092,6 @@ impl Game<'_> {
Some(draws) => draws,
None => self.health_hud.layout(&inputs, wi, hi),
};
self.keep_status_rows(&draws, wi, hi);
// The rows draw in one pass here, so a mod's `player_health`
// layer stands for the armor, food and air rows too.
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
@@ -6193,9 +6191,7 @@ impl Game<'_> {
if self.screen == Screen::Chat {
self.draw_chat(&mut batch);
} else if show_hud {
if !self.overlay_holds_view() {
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
}
self.gui_layer("minecraft:chat", &mut batch, |g, batch| g.draw_chat(batch));
self.gui_layer("minecraft:tab_list", &mut batch, |g, batch| {
g.draw_tab_list(batch);
});
@@ -6266,7 +6262,11 @@ impl Game<'_> {
// after this point and so over what a mod drew.
self.fire_screen_render_post(&mut batch);
self.draw_saving_indicator(&mut batch);
self.draw_every_toast(&mut batch, !show_hud);
// ToastManager.extractRenderState: F1 hides the toasts too.
self.draw_toasts(&mut batch, !show_hud);
self.draw_tutorial_toasts(&mut batch, !show_hud);
self.draw_pack_toasts(&mut batch, !show_hud);
self.draw_music_toast(&mut batch, !show_hud);
if self.server_packs.prompt.is_none() {
self.remote_hud(&mut batch);
self.draw_sign_editor(&mut batch);
@@ -9424,7 +9424,6 @@ impl Game<'_> {
self.camera_type.first_person(),
self.ui.hide_gui,
self.shows_panorama(),
self.overlay_holds_view(),
) {
let scale = HudBatch::new(size).scale;
crosshair.debug_crosshair(at, view_pitch, view_yaw, scale);
@@ -9980,8 +9979,10 @@ impl Game<'_> {
&mut self.toggles,
[self.settings.toggle_crouch, self.settings.toggle_sprint],
[
self.pad.holds_action("key.sneak"),
self.pad.holds_action("key.sprint"),
self.pad
.holds_action(&self.settings.controller, "key.sneak"),
self.pad
.holds_action(&self.settings.controller, "key.sprint"),
],
);
self.sync_toggle(SNEAK);
@@ -11992,7 +11993,7 @@ impl<'a> Game<'a> {
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
water_plane_scan: Default::default(),
vehicle_mobs: Default::default(),
vehicle_mob_reach: Default::default(),
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
@@ -12527,7 +12528,7 @@ pub fn run_with() -> Result<()> {
narrator::start(&early_settings);
}
// A game window's hooks (Discord's presence): never for a server.
activity::client_started(&options);
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"));
+10 -2
View File
@@ -6,7 +6,7 @@ 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) {
pub(super) fn client_started(options: &Options, settings: &Settings) {
if options.dedicated.is_some() {
return;
}
@@ -14,7 +14,12 @@ pub(super) fn client_started(options: &Options) {
|| options.offscreen
|| options.frames.is_some()
|| options.world_frames.is_some();
horizon_hooks::engine::activity(Activity::Menus);
// 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));
}
@@ -28,6 +33,9 @@ impl Game<'_> {
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;
}
@@ -1255,7 +1255,7 @@ impl Game<'_> {
}
self.check_inventory_trigger();
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
let me = self.player_entity_if_listened("minecraft:tick");
let me = self.player_entity_for("minecraft:tick");
self.trigger("minecraft:tick", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -1289,7 +1289,7 @@ impl Game<'_> {
}
self.tick_marks();
if self.ticks.is_multiple_of(20) {
let me = self.player_entity_if_listened("minecraft:location");
let me = self.player_entity_for("minecraft:location");
self.trigger("minecraft:location", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -1,158 +0,0 @@
//! An allay dances to the jukebox it heard first: `Allay.setJukeboxPlaying`
//! keeps that jukebox's position while it dances, and only a stop from there
//! ends the dance; another jukebox's `jukebox_play` (every 20 ticks of its
//! song) starts it again (tools/test_allay_jukebox_source_parity.py measures
//! the same on vanilla 26.3).
use crate::game::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
for command in [
"tick freeze",
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
"fill 7 99 7 9 102 9 glass",
"fill 8 100 8 8 101 8 air",
"setblock 5 100 8 jukebox",
"setblock 12 100 8 jukebox",
"summon allay 8.5 100 8.5 {PersistenceRequired:1b,UUID:[I;2577,0,0,1]}",
] {
assert!(game.run_command(command), "{command}");
}
step(&mut game, 12);
game
}
fn step(game: &mut Game<'_>, ticks: usize) {
for _ in 0..ticks {
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
}
fn dancing(game: &Game<'_>) -> bool {
game.mobs
.iter()
.find(|m| m.species() == horizon_client::mobs::Species::Allay)
.expect("the allay")
.extra
.flag
}
fn play(game: &mut Game<'_>, x: i32, item: &str) {
assert!(game.run_command(&format!(
"item replace block {x} 100 8 container.0 with {item}"
)));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn stopping_the_first_jukebox_stops_the_dance_until_the_other_plays_again() {
let mut game = game();
assert!(!dancing(&game));
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 5);
assert!(dancing(&game), "jukebox A starts the dance");
play(&mut game, 12, "minecraft:music_disc_cat");
step(&mut game, 25);
assert!(dancing(&game));
// B has played for 25 ticks: its next jukebox_play is 15 ticks away.
play(&mut game, 5, "minecraft:air");
step(&mut game, 1);
assert!(
!dancing(&game),
"the stop of A, the dance's jukebox, ends it though B still plays"
);
step(&mut game, 20);
assert!(dancing(&game), "B's next jukebox_play starts it again");
}
// Controls added after the baseline: the source through an in-place load, a
// NoAI allay, and the out-of-reach check of `aiStep`.
const ALLAY: &str = "00000a11-0000-0000-0000-000000000001";
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_data_merge_keeps_the_dance_and_its_jukebox() {
let mut game = game();
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 5);
play(&mut game, 12, "minecraft:music_disc_cat");
step(&mut game, 5);
assert!(game.run_command(&format!("data merge entity {ALLAY} {{CustomName:\"x\"}}")));
step(&mut game, 1);
assert!(dancing(&game), "Entity.load keeps DATA_DANCING");
// The kept jukebox is still A: B's stop does not end the dance, A's does.
play(&mut game, 12, "minecraft:air");
step(&mut game, 1);
assert!(dancing(&game));
play(&mut game, 5, "minecraft:air");
step(&mut game, 1);
assert!(!dancing(&game));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_a_no_ai_allay_never_dances() {
let mut game = game();
assert!(game.run_command(&format!("data merge entity {ALLAY} {{NoAI:1b}}")));
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 25);
assert!(!dancing(&game));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_an_allay_out_of_reach_stops_on_a_twentieth_tick() {
let mut game = game();
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 2);
assert!(dancing(&game));
assert!(game.run_command("fill 7 99 7 9 102 9 air"));
assert!(game.run_command(&format!("tp {ALLAY} 5.5 112 8.5")));
// Farther than 10 blocks from A's centre: stopped by the next tickCount % 20.
step(&mut game, 20);
assert!(!dancing(&game));
}
+24 -6
View File
@@ -3,6 +3,7 @@
//! and the hand-over interaction (`Allay.mobInteract`).
use super::Game;
use horizon_client::{
containers::BlockEntity,
first_person::Hand,
items::ItemStack,
mobs::{Species, allay},
@@ -73,6 +74,9 @@ impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
for i in 0..self.mobs.len() {
if self.mobs[i].species() != Species::Allay
|| !self.mobs[i].alive()
@@ -81,8 +85,26 @@ impl Game<'_> {
continue;
}
let pos = self.mobs[i].position;
// The dance follows the jukebox events the allay hears
// (`mob_listen`), `Allay.tick` and `aiStep`.
// Dancing: a playing jukebox within 10 blocks.
let jukeboxes = jukeboxes.get_or_insert_with(|| {
self.world
.block_entities
.iter()
.filter(|(_, e)| matches!(e, BlockEntity::Jukebox(j) if j.playing))
.map(|(k, _)| *k)
.collect()
});
let nearest = jukeboxes
.iter()
.map(|k| {
(f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
})
.fold(f64::INFINITY, f64::min);
// `Allay.setDancing` wants effective AI: a `NoAI` allay never dances.
let panicking = horizon_client::mobs::goals::should_panic(&self.mobs[i]);
self.mobs[i].extra.flag = allay::dances(self.mobs[i].no_ai, panicking, nearest);
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
self.mobs[i].extra.point = None;
continue;
@@ -403,7 +425,3 @@ impl Game<'_> {
false
}
}
#[cfg(test)]
#[path = "allay_jukebox_source_tests.rs"]
mod allay_jukebox_source_tests;
+13 -92
View File
@@ -1278,21 +1278,10 @@ impl Game<'_> {
.raw
.get(&id)
.is_some_and(|tag| horizon_client::entity_nbt::flag(tag, "Invulnerable"));
// Entity.isInvulnerableTo: the flag, then EnchantmentHelper.isImmuneToDamage.
let a = &self.armor_stands[i];
if (invulnerable && !creative)
|| a.invisible
|| a.marker
|| self.stand_immune_to_damage(i, damage_type, causing)
{
if (invulnerable && !creative) || a.invisible || a.marker {
return false;
}
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
let (explosion, ignites, burns) = (
tagged("minecraft:is_explosion"),
tagged("minecraft:ignites_armor_stands"),
@@ -1303,26 +1292,21 @@ impl Game<'_> {
tagged("minecraft:always_kills_armor_stands"),
);
if explosion {
self.break_armor_stand_with_source(i, by);
self.break_armor_stand_by_anything(i);
return false;
}
if ignites {
if self.armor_stands[i].fire_ticks > 0 {
self.cause_stand_damage(i, 0.15, by);
self.cause_stand_damage(i, 0.15);
} else {
// igniteForSeconds(5.0f): at least 100 ticks, and
// Entity.igniteForTicks calls clearFreeze.
// igniteForSeconds(5.0f): at least 100 ticks.
let a = &mut self.armor_stands[i];
a.fire_ticks = a.fire_ticks.max(100);
if let Some(horizon_world::nbt::Tag::Compound(map)) = self.persist.raw.get_mut(&id)
{
map.remove(&"TicksFrozen".into());
}
}
return false;
}
if burns && self.armor_stands[i].health > 0.5 {
self.cause_stand_damage(i, 4.0, by);
self.cause_stand_damage(i, 4.0);
return false;
}
if !breaks && !always_kills {
@@ -1337,85 +1321,22 @@ impl Game<'_> {
if !may_build {
return false;
}
self.break_armor_stand_by_hit(i, creative, always_kills, by)
}
/// `EnchantmentHelper.isImmuneToDamage` for the stand at `i`: whether an
/// enchantment on an active equipment slot has a `minecraft:damage_immunity`
/// effect whose requirements hold for `damage_type` and `causing`.
fn stand_immune_to_damage(
&mut self,
i: usize,
damage_type: &str,
causing: Option<super::commands::Target>,
) -> bool {
use super::commands::Target;
let mut checks = Vec::new();
let equipment = &self.armor_stands[i].equipment;
for (stack, slot) in equipment.iter().zip(ArmorStand::SLOTS) {
let Some(stack) = stack else { continue };
let armor = matches!(slot, "head" | "chest" | "legs" | "feet");
let hand = matches!(slot, "mainhand" | "offhand");
for (id, level) in stack.enchantments() {
let Some(def) = self.res.stations().enchantments.get(id) else {
continue;
};
if !def.slots.iter().any(|s| {
s == slot || s == "any" || (s == "armor" && armor) || (s == "hand" && hand)
}) {
continue;
}
let immunity = &def.effects["minecraft:damage_immunity"];
for entry in immunity.as_array().into_iter().flatten() {
checks.push((entry.get("requirements").cloned(), *level));
}
}
}
if checks.is_empty() {
return false;
}
let attacker = match causing {
Some(Target::Player | Target::Guest(_)) => Some("minecraft:player".to_owned()),
Some(Target::Mob(m_id)) => self
.mobs
.iter()
.find(|m| m.id == m_id)
.map(|m| m.kind.id.clone()),
Some(Target::Stand(_)) => Some("minecraft:armor_stand".to_owned()),
// ArmorStand.hurtServer attributes its game events to the source
// entity: the player, a LAN guest or the mob.
let by = match causing {
Some(Target::Player | Target::Guest(_) | Target::Mob(_)) => causing,
_ => None,
};
let canonical = if damage_type.contains(':') {
damage_type.to_owned()
} else {
format!("minecraft:{damage_type}")
};
let ctx = horizon_client::enchant::EffectContext {
items: Some(self.res.items()),
damage_type: Some(&canonical),
is_direct: attacker.is_some(),
this: Some("minecraft:armor_stand"),
attacker: attacker.as_deref(),
direct_attacker: attacker.as_deref(),
..Default::default()
};
let ench = &self.res.stations().enchantments;
let rng = &mut self.world.random;
for (req, level) in checks {
if req.is_none_or(|r| ench.condition(&r, level, &ctx, rng)) {
return true;
}
}
false
self.break_armor_stand_by_hit(i, creative, always_kills, by)
}
/// `ArmorStand.causeDamage`: the health less `amount`, broken by anything
/// at 0.5 or less, else `gameEvent(ENTITY_DAMAGE, damageSource.getEntity())`.
fn cause_stand_damage(&mut self, i: usize, amount: f32, by: Option<super::commands::Target>) {
/// at 0.5 or less.
fn cause_stand_damage(&mut self, i: usize, amount: f32) {
let health = self.armor_stands[i].health - amount;
if health <= 0.5 {
self.break_armor_stand_with_source(i, by);
self.break_armor_stand_by_anything(i);
} else {
let at = self.armor_stands[i].position;
self.armor_stands[i].health = health;
self.stand_game_event("minecraft:entity_damage", [at.x, at.y, at.z], by);
}
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
+42 -313
View File
@@ -7,13 +7,10 @@
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
use horizon_client::{
camera::wrap_degrees,
fast_hash::FxHashMap,
join_seq::ListStamp,
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders, seen_grid::SeenGrid},
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders},
scoreboard::pushable_by,
};
use horizon_core::geometry::{Aabb, Vec3};
use std::cell::{Cell, RefCell};
/// `Entity.boardingCooldown` once a mob leaves its boat.
pub(super) const BOARDING_COOLDOWN: i32 = 60;
@@ -21,35 +18,6 @@ pub(super) const BOARDING_COOLDOWN: i32 = 60;
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
/// How many box queries the walk over every mob answers, once a write to the mob list has
/// made the grid stale, before a new grid is built: about what building one costs (with
/// 25,000 mobs, six to eight walks). It doubles with each grid of a stretch, so vehicles
/// that each write a mob (a boat pushing the pig beside it) never pay more for grids than
/// for their walks.
const WALKS_BEFORE_GRID: u32 = 8;
/// What a stretch of vehicle ticks knows about the mobs, so a boat or a minecart reads the
/// mobs near it and not every mob of the level ([`Game::mobs_meeting`]).
#[derive(Default)]
pub(super) struct VehicleMobs {
/// The mobs' [`BoxReach`] with the mob count it was worked out for; None outside a stretch.
reach: Cell<Option<(usize, BoxReach)>>,
grid: RefCell<MobGrid>,
/// The last query's candidates, kept for their allocation.
near: Cell<Vec<u32>>,
/// Each ridden vehicle's mobs by slot, for the mob list as it stood ([`Game::vehicle_riders`]).
riders: RefCell<Option<(ListStamp, FxHashMap<u32, Vec<usize>>)>>,
}
#[derive(Default)]
struct MobGrid {
/// The mobs' boxes by column and the list they were read from: they hold until that
/// list's next write ([`ListStamp`]), which every push, pick-up, death and join is.
built: Option<(ListStamp, SeenGrid)>,
/// The queries walked since the grid went stale, and how many are before the next grid.
walked: u32,
patience: u32,
}
/// `Entity.canRide` for a mob species: `Warden` and `WitherBoss` override it
/// to return false, so no vehicle ever takes them aboard.
pub(super) fn species_can_ride(species: Species) -> bool {
@@ -98,165 +66,32 @@ pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool
}
impl Game<'_> {
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
/// a vehicle's box query ([`Self::mobs_meeting`]) reads the mobs near it from a grid of
/// their boxes, or tests the mobs' positions against their [`BoxReach`] first. A mob
/// joining or leaving works the reach out again, and so does [`Self::vehicle_picked_up`].
/// The first query that needs one works it out (the reach's count is one no list has), so
/// a level without boats or carts never walks its mobs for them.
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
/// A mob joining or leaving works it out again, and so does [`Self::vehicle_picked_up`].
pub(super) fn vehicles_ticking(&self, on: bool) {
let reach = on.then(|| (usize::MAX, BoxReach::default()));
self.vehicle_mobs.reach.set(reach);
// The boats' grid serves the carts ticking right after them; an older one goes.
let mut grid = self.vehicle_mobs.grid.borrow_mut();
let stamp = self.mobs.stamp();
let built = grid.built.take().filter(|(s, _)| *s == stamp);
*grid = MobGrid {
built,
..Default::default()
};
self.vehicle_mob_reach
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
}
/// A vehicle picked a mob up: a shulker turns its lid up (`Shulker.startRiding`), which
/// changes its box, so the next query works the [`BoxReach`] out again.
pub(super) fn vehicle_picked_up(&self) {
if let Some((_, reach)) = self.vehicle_mobs.reach.get() {
self.vehicle_mobs.reach.set(Some((usize::MAX, reach)));
if let Some((_, reach)) = self.vehicle_mob_reach.get() {
self.vehicle_mob_reach.set(Some((usize::MAX, reach)));
}
}
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
let (count, reach) = self.vehicle_mobs.reach.get()?;
let (count, reach) = self.vehicle_mob_reach.get()?;
if count == self.mobs.len() {
return Some(reach);
}
let reach = BoxReach::of(&self.mobs);
self.vehicle_mobs.reach.set(Some((self.mobs.len(), reach)));
self.vehicle_mob_reach.set(Some((self.mobs.len(), reach)));
Some(reach)
}
/// The slots, ascending, of the mobs whose vehicle is `id`, whatever their state. While
/// vehicles tick, one walk over the mobs answers for every vehicle until the mob list is
/// next written: each boat asks for its passengers and each cart on rails whether it is
/// ridden, and each one walked every mob of the level.
fn vehicle_riders(&self, id: u32) -> Vec<usize> {
let rides = |v: u32| move |&j: &usize| self.mobs[j].extra.mount == Some(v);
if !self.vehicle_mobs_indexed() {
return (0..self.mobs.len()).filter(rides(id)).collect();
}
let stamp = self.mobs.stamp();
let mut riders = self.vehicle_mobs.riders.borrow_mut();
if riders.as_ref().is_none_or(|(s, _)| *s != stamp) {
let mut by_vehicle = FxHashMap::<u32, Vec<usize>>::default();
for (j, m) in self.mobs.iter().enumerate() {
if let Some(v) = m.extra.mount {
by_vehicle.entry(v).or_default().push(j);
}
}
*riders = Some((stamp, by_vehicle));
}
let of = riders
.as_ref()
.and_then(|(_, by_vehicle)| by_vehicle.get(&id));
of.cloned().unwrap_or_default()
}
/// Whether a mob (one not removed) rides vehicle `id`: `Entity.isVehicle` for a cart.
pub(super) fn mob_rides(&self, id: u32) -> bool {
let riders = self.vehicle_riders(id);
riders.iter().any(|&j| !self.mobs[j].removed)
}
/// Whether what a stretch of vehicle ticks reads from the mob list may stand in for a walk
/// over it: a stretch runs, and no native mod is loaded (a hook can move a mob unseen, as
/// for the carts' `CartIndex`).
fn vehicle_mobs_indexed(&self) -> bool {
self.vehicle_mobs.reach.get().is_some() && horizon_client::mod_api::mods().is_empty()
}
/// The slots, ascending, of the mobs whose box may meet `area` into `out` (a superset: the
/// caller still tests each box), from a grid of the mobs' boxes that the first query of a
/// stretch of vehicle ticks builds. False: walk them all. That is outside a stretch or with
/// native mods loaded ([`Self::vehicle_mobs_indexed`]), when the grid cannot say, and for
/// a while after a write to the mob list ([`MobGrid`]).
fn vehicle_mobs_near(&self, area: Aabb, out: &mut Vec<u32>) -> bool {
if !self.vehicle_mobs_indexed() {
return false;
}
let stamp = self.mobs.stamp();
let mut grid = self.vehicle_mobs.grid.borrow_mut();
if grid.built.as_ref().is_none_or(|(s, _)| *s != stamp) {
if grid.walked < grid.patience {
grid.walked += 1;
return false;
}
grid.built = Some((stamp, SeenGrid::new(self.mobs.iter().map(Mob::bounds))));
grid.walked = 0;
grid.patience = (grid.patience * 2).max(WALKS_BEFORE_GRID);
}
let built = grid.built.as_ref();
built.is_some_and(|(_, cells)| cells.candidates(area, out))
}
/// A vehicle's box query: `each` gets every mob whose box meets `area`, in slot order,
/// with its slot and its box. While vehicles tick it reads the mobs near the area
/// ([`Self::vehicle_mobs_near`]) or, walking them all, tests each position against the
/// [`BoxReach`] before working the box out. The mobs and their order are those of a walk
/// that tests every box.
pub(super) fn mobs_meeting(&self, area: Aabb, mut each: impl FnMut(usize, &Mob, Aabb)) {
let mut near = self.vehicle_mobs.near.take();
if self.vehicle_mobs_near(area, &mut near) {
for &j in &near {
let m = &self.mobs[j as usize];
let b = m.bounds();
if b.intersects(area) {
each(j as usize, m, b);
}
}
} else {
let reach = self.vehicle_mob_reach();
for (j, m) in self.mobs.iter().enumerate() {
if reach.is_some_and(|r| !r.may_meet(m, area)) {
continue;
}
let b = m.bounds();
if b.intersects(area) {
each(j, m, b);
}
}
}
self.vehicle_mobs.near.set(near);
}
/// The slots, ascending, of the mobs whose box may meet a cart, read from the mobs' grid
/// once per cart. None when the mobs are the fewer (asking the carts' grid once per mob
/// is then the shorter way) or the grid does not answer.
pub(super) fn mobs_near_carts(&self) -> Option<Vec<u32>> {
if self.mobs.len() <= self.carts.len() {
return None;
}
let (mut slots, mut near) = (Vec::new(), Vec::new());
for c in self.carts.iter() {
if !self.vehicle_mobs_near(c.bounds(), &mut near) {
return None;
}
slots.extend_from_slice(&near);
}
slots.sort_unstable();
slots.dedup();
Some(slots)
}
/// Whether mob `m`, whose box `reach` bounds, may touch a cart: the carts' grid (`cells`)
/// names the carts near it, or every cart when the grid cannot (a superset either way).
pub(super) fn mob_may_meet_cart(
&self,
reach: BoxReach,
m: &Mob,
cells: &SeenGrid,
near: &mut Vec<u32>,
) -> bool {
if !cells.candidates(reach.around(m.position), near) {
near.clear();
near.extend(0..self.carts.len() as u32);
}
near.iter()
.any(|&k| reach.may_meet(m, self.carts[k as usize].bounds()))
}
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
pub(super) fn boat_players(&self, id: u32) -> usize {
let guests = self.lan.as_ref().map_or(0, |h| {
h.hub
@@ -270,9 +105,12 @@ impl Game<'_> {
/// The mobs in boat `id`, in their seat order after the players
/// (`Entity.addPassenger` puts a player first).
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
let mut riders = self.vehicle_riders(id);
riders.retain(|&j| !self.mobs[j].removed && self.mobs[j].alive());
riders
(0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
m.extra.mount == Some(id) && !m.removed && m.alive()
})
.collect()
}
/// `getPassengers().size()` of boat `id`.
pub(super) fn boat_passengers(&self, id: u32) -> usize {
@@ -336,12 +174,18 @@ impl Game<'_> {
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
/// shulkers and pushable mobs and players, but not its own passengers.
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
let mut out = Vec::new();
self.mobs_meeting(area, |j, m, b| {
if pushable(m) && (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id)) {
out.push(b);
}
});
let reach = self.vehicle_mob_reach();
let mut out: Vec<Aabb> = (0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
// The box first: it turns away nearly every mob of the level.
reach.is_none_or(|r| r.may_meet(m, area))
&& m.bounds().intersects(area)
&& pushable(m)
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
})
.map(|j| self.mobs[j].bounds())
.collect();
out.extend(
self.boats
.iter()
@@ -386,14 +230,19 @@ impl Game<'_> {
let pick_up = self.boat_players(id) == 0;
// canAddPassenger, which startRiding asks after the checks below.
let room = !self.boats[i].under_water();
// The mobs near the two boxes: the teams are only worked out when something
// touches the boat.
let mut near = Vec::new();
self.mobs_meeting(area.union_bounds(bb), |j, _, mb| {
if mb.intersects(area) || mb.intersects(bb) {
near.push(j);
}
});
// The box first: it turns away nearly every mob of the level, and the
// teams are only worked out when something touches the boat.
let reach = self.vehicle_mob_reach();
let sweep = area.union_bounds(bb);
let near: Vec<usize> = (0..self.mobs.len())
.filter(|&j| {
if reach.is_some_and(|r| !r.may_meet(&self.mobs[j], sweep)) {
return false;
}
let mb = self.mobs[j].bounds();
mb.intersects(area) || mb.intersects(bb)
})
.collect();
let outside = self.players_outside(id);
let touching = !near.is_empty()
|| outside
@@ -682,124 +531,4 @@ mod tests {
assert!(boat_pushes(boat, at(64.5), true));
assert!(!boat_pushes(boat, at(64.5625), true));
}
/// A mob pushes the cart its box touches while a mob far from every cart pushes none and
/// is not pushed, whichever grid finds them: the carts' (four carts in four of its columns,
/// asked once per mob while the mobs are the fewer) or, in a crowd, the mobs' (asked once
/// per cart).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn only_the_mob_touching_a_cart_pushes_it() {
use super::super::minecarts::{CartKind, Minecart};
let pushed = [0, 12].map(|crowd: u32| {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
for (i, (x, z)) in [(0.5, 0.5), (20.5, 0.5), (0.5, 20.5), (20.5, 20.5)]
.into_iter()
.enumerate()
{
let at = Vec3::new(x, 64.0, z);
game.carts
.push(Minecart::new(100 + i as u32, CartKind::Chest, at));
}
// 0.5 west of the second cart (boxes 0.9 and 0.98 wide), in the middle of them
// all, and the crowd well away.
let spots = [(20.0, 0.5), (10.5, 10.5)].into_iter();
let crowd = (0..crowd).map(|i| (40.5 + 3.0 * f64::from(i), 40.5));
for (i, (x, z)) in spots.chain(crowd).enumerate() {
let at = Vec3::new(x, 64.0, z);
game.mobs.push(Mob::new(1 + i as u32, pig.clone(), at, 0.0));
}
game.tick_carts();
let velocity = |id| game.mobs.iter().find(|m| m.id == id).unwrap().velocity;
assert!(velocity(1).x < 0.0, "pushed back west: {:?}", velocity(1));
let idle = game.mobs.iter().filter(|m| m.velocity == Vec3::ZERO);
assert_eq!(idle.count(), game.mobs.len() - 1);
velocity(1)
});
assert_eq!(pushed[0], pushed[1]);
}
/// A vehicle's box query during a stretch of vehicle ticks names the mobs a walk over
/// every box names, in the same order, also right after a mob moved into the box (a
/// push or a pick-up writes the mob list in the middle of a stretch).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vehicles_box_query_matches_a_walk_over_every_mob() {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
// A small LCG keeps the scene the same on every run.
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
for id in 1..=400 {
let at = Vec3::new(next(48.0), 64.0 + next(3.0), next(48.0));
game.mobs.push(Mob::new(id, pig.clone(), at, 0.0));
}
let mut met = 0;
for round in 0..300 {
// Three stretches, each with two writes to the mob list.
if round % 100 == 0 {
game.vehicles_ticking(true);
}
let corner = Vec3::new(next(46.0), 63.0 + next(3.0), next(46.0));
let size = Vec3::new(0.5 + next(2.5), 0.5 + next(1.5), 0.5 + next(2.5));
let area = Aabb::new(corner, corner.add_vector(size));
if round % 40 == 20 {
let j = round % game.mobs.len();
game.mobs[j].position = area.center();
}
let walked: Vec<Aabb> = game
.mobs
.iter()
.map(Mob::bounds)
.filter(|b| b.intersects(area))
.collect();
met += walked.len();
assert_eq!(game.boat_obstacles(u32::MAX, area), walked, "round {round}");
if round % 100 == 99 {
// The last comparisons ran against the grid: it stands, for the list as it is.
let fresh = |(stamp, _): &(ListStamp, SeenGrid)| *stamp == game.mobs.stamp();
assert!(
game.vehicle_mobs
.grid
.borrow()
.built
.as_ref()
.is_some_and(fresh)
);
game.vehicles_ticking(false);
}
}
assert!(met > 100, "the boxes met {met} mobs");
}
/// Who rides a vehicle follows the mob list through a stretch of vehicle ticks: a mob that
/// boards counts from then on, one removed no longer does, and a dead one still makes
/// its cart a vehicle but is no passenger of a boat.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vehicle_knows_its_mob_riders_as_soon_as_they_board() {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
for id in 1..=4 {
game.mobs.push(Mob::new(id, pig.clone(), Vec3::ZERO, 0.0));
}
let riders =
|game: &Game| [100, 101, 102].map(|id| (game.mob_rides(id), game.boat_mobs(id)));
game.mobs[0].extra.mount = Some(100);
game.vehicles_ticking(true);
let none = (false, vec![]);
assert_eq!(riders(&game), [(true, vec![0]), none.clone(), none.clone()]);
game.mobs[0].removed = true;
game.mobs[1].extra.mount = Some(101);
game.mobs[3].extra.mount = Some(102);
game.mobs[3].health = 0.0;
let after = [none, (true, vec![1]), (true, vec![])];
assert_eq!(riders(&game), after);
game.vehicles_ticking(false);
assert_eq!(riders(&game), after);
}
}
-11
View File
@@ -1467,13 +1467,6 @@ impl Game<'_> {
}
}
/// `BoatItem.use`: aim at a block or the water surface.
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.yaw = yaw;
b.previous_yaw = yaw;
}
}
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(boat) = self.boat_to_place(stack) else {
return false;
@@ -1484,11 +1477,7 @@ impl Game<'_> {
boat.position.z.floor() as i32 >> 4,
);
self.world.modified.insert(key);
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
self.boats.push(boat);
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_vehicle_create(
self.boats.last().unwrap().entity_type().as_str(),
+9 -98
View File
@@ -1594,17 +1594,15 @@ impl Game<'_> {
&tr("gui.done", "Done"),
);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: a clickable word
// asks for the hand, and a show_text hover shows its tooltip,
// as the chat does.
if let Some(style) = pages.get(*page).and_then(|t| self.book_style_at(t, left)) {
if let Some(cursor) = link_cursor(&style) {
horizon_client::widgets::request_cursor(cursor);
}
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
self.draw_tooltip_lines(batch, &lines, mx, my);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
// show_text hover event, as the chat shows it.
if let Some(hover) = pages
.get(*page)
.and_then(|t| self.book_style_at(t, left))
.and_then(|s| s.hover)
{
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
self.draw_tooltip_lines(batch, &lines, mx, my);
}
}
}
@@ -1649,16 +1647,6 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
}
}
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: only a word with a click event asks
/// for the pointing hand over a book's page; a hover event alone shows its
/// tooltip.
fn link_cursor(style: &horizon_client::text::Style) -> Option<horizon_client::widgets::Cursor> {
style
.click
.is_some()
.then_some(horizon_client::widgets::Cursor::PointingHand)
}
#[cfg(test)]
mod title_box_tests {
use super::book_title_box;
@@ -1848,60 +1836,6 @@ mod lectern_signal_tests {
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
}
}
impl BookScreen {
/// `BookViewScreen.isPauseScreen`: the class inherits `Screen`'s true, so
/// a book read in hand stops a single-player world. `LecternScreen`
/// overrides it false, so the same pages read on a lectern leave the
/// world ticking (its menu is an `AbstractContainerScreen`, which does
/// not pause either).
pub(super) fn pauses_world(&self) -> bool {
!matches!(
self,
Self::View {
lectern: Some(_),
..
}
)
}
}
#[cfg(test)]
mod pause_tests {
use super::BookScreen;
use horizon_client::{text::Text, ui};
fn view(lectern: Option<(i32, i32, i32)>) -> BookScreen {
BookScreen::View {
pages: Vec::<Text>::new(),
page: 0,
lectern,
}
}
#[test]
fn a_lecterns_book_does_not_pause_the_world() {
// BookViewScreen keeps Screen.isPauseScreen's true; LecternScreen
// overrides it false although both read the same pages.
assert!(view(None).pauses_world());
assert!(!view(Some((10, 64, -3))).pauses_world());
}
#[test]
fn a_book_being_edited_or_signed_pauses() {
// BookEditScreen and BookSignScreen do not override isPauseScreen.
let edit = |signing| BookScreen::Edit {
slot: 0,
pages: Vec::new(),
page: 0,
signing,
title: String::new(),
title_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
page_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
};
assert!(edit(false).pauses_world());
assert!(edit(true).pauses_world());
}
}
#[cfg(test)]
mod lectern_reach_tests {
@@ -1995,26 +1929,3 @@ mod sign_tests {
}
}
}
#[cfg(test)]
mod link_cursor_tests {
use super::link_cursor;
use horizon_client::text::{ClickEvent, Style, Text};
use horizon_client::widgets::Cursor;
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: a clickable word asks for the
/// pointing hand; a hover event alone only shows its tooltip.
#[test]
fn only_a_clickable_book_word_asks_for_the_hand() {
let link = Style {
click: Some(ClickEvent::OpenUrl("https://example.com".into())),
..Style::default()
};
let hover_only = Style {
hover: Some(Box::new(Text::literal("tip"))),
..Style::default()
};
assert_eq!(link_cursor(&link), Some(Cursor::PointingHand));
assert_eq!(link_cursor(&hover_only), None);
}
}
+18 -201
View File
@@ -1,10 +1,9 @@
//! Breeze particles (`Breeze.tick`): block dust at its feet while it stands,
//! a burst of twenty while it slides and a short trail along a long jump.
use super::{BlockStateId, Game};
use super::Game;
use horizon_client::{
blocks::RenderShape,
mobs::{Mob, Species, breeze},
particles::{ParticleOptions, ParticleType},
mobs::{Species, breeze},
particles::Particle,
};
impl Game<'_> {
@@ -34,16 +33,21 @@ impl Game<'_> {
} else {
self.mobs[i].extra.scratch[1] = 0;
}
let (block, state) = self.breeze_ground(&self.mobs[i]);
// `emitGroundParticles` skips an invisible block; the jump trail
// (`emitJumpTrailParticles`) does not ask.
if !jump_trail && self.res.facts().get(state).render_shape == RenderShape::Invisible {
continue;
let m = &self.mobs[i];
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let mut at = [x, y, z];
let mut state = self.world.block(x, y, z);
if self.res.facts().is_air(state) {
at[1] = (m.position.y - 0.2).floor() as i32;
state = self.world.block(x, at[1], z);
}
let Some((sprite, tint)) = self.particle_sprite(state, block) else {
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let m = &self.mobs[i];
let at = if jump_trail {
[
m.position.x + m.velocity.x,
@@ -53,198 +57,11 @@ impl Game<'_> {
} else {
[m.position.x, m.position.y, m.position.z]
};
// `ClientLevel.addParticle` with no speed: each crumb's own
// generator draws its lifetime, its little push and the quarter of
// the block's sprite it shows, never the level's random.
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
for _ in 0..amount {
self.particles.add(&options, at, [0.0; 3]);
let particle =
Particle::terrain(&mut self.world.random, at, [0.0; 3], sprite.clone(), tint);
self.particles.push(particle);
}
}
}
/// `Breeze.emitGroundParticles`'s block: `getInBlockState`, else
/// `getBlockStateOn`, which is `Entity.getOnPos(1.0E-5F)` — the supporting
/// block the feet rest on, or the cell just below them. A fence, a wall and
/// a fence gate have a 1.5-block collision, so a breeze on one has its feet
/// half a block above that block's own cell, where the cell below them is
/// air and only the supporting block has the material.
pub(super) fn breeze_ground(&self, m: &Mob) -> ([i32; 3], BlockStateId) {
let feet = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
let in_block = self.world.block(feet[0], feet[1], feet[2]);
if !self.res.facts().is_air(in_block) {
return (feet, in_block);
}
let on_ground = m
.on_ground
.then(|| self.supporting_block(m.position, m.bounds()));
let at = on_ground.flatten().map_or(
[
feet[0],
(m.position.y - f64::from(1.0e-5_f32)).floor() as i32,
feet[2],
],
|p| [p.x, p.y, p.z],
);
(at, self.world.block(at[0], at[1], at[2]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::mobs::Mob;
use horizon_core::geometry::Vec3;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// A game on the prepared resources with an empty chunk at (0, 0), the
/// window's player (the camera the dust needs) and one breeze standing
/// with its feet at `feet`.
fn game_with_breeze(feet: Vec3) -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
false,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
// Eight blocks south of the breeze: inside the 32 blocks the dust needs.
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 16.5))
.unwrap(),
);
game.particles.camera = [8.5, 66.0, 16.5];
let kind = game.res.mob_types()["minecraft:breeze"].clone();
game.mobs.push(Mob::new(13001, kind, feet, 0.0));
game
}
/// A breeze standing on a fence rests its feet at 64 + 1.5, where its own
/// cell is air: `Breeze.emitGroundParticles` asks `getBlockStateOn` for the
/// supporting block, so the crumbs are the fence's. Every one of them is
/// made by the particle's own generator (`Particle.random`), so the
/// client level's random is where it was before the tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_dust_of_a_breeze_on_a_fence_keeps_the_client_level_random() {
let mut game = game_with_breeze(Vec3::new(8.5, 65.5, 8.5));
let registry = &game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let fence = registry.default_state("minecraft:oak_fence").unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.world.set(8, 64, 8, fence).unwrap();
game.mobs[0].on_ground = true;
assert_eq!(
game.breeze_ground(&game.mobs[0]),
([8, 64, 8], fence),
"the feet's own cell is air; the fence is what holds them up"
);
game.world.random = LegacyRandom::new(1234);
let level = game.world.random.state();
game.tick_breeze_effects();
assert_eq!(
game.particles.made().get("block").copied().unwrap_or(0),
1,
"vanilla emits one crumb of the fence the breeze stands on"
);
assert_eq!(
game.world.random.state(),
level,
"the crumb's constructor draws belong to the particle, not the level"
);
}
/// The control: a breeze standing on a full block's top also crumbles it,
/// and its constructor's draws still leave the client level's random alone.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_dust_of_a_breeze_on_a_full_block_keeps_the_client_level_random() {
let mut game = game_with_breeze(Vec3::new(8.5, 64.0, 8.5));
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.mobs[0].on_ground = true;
game.world.random = LegacyRandom::new(1234);
let level = game.world.random.state();
game.tick_breeze_effects();
assert_eq!(
game.world.random.state(),
level,
"the crumb's constructor draws belong to the particle, not the level"
);
assert_eq!(game.particles.made().get("block").copied().unwrap_or(0), 1);
}
/// `Entity.getOnPos(1.0E-5F)` keeps a fence's, a wall's or a fence gate's
/// own cell while the feet rest on its 1.5-block collision; on a full block
/// it is the block below the feet. Reading 0.2 below the feet, as the dust
/// used to, lands in air on all three.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_ground_of_a_breeze_is_the_fence_wall_or_gate_it_stands_on() {
let mut game = game_with_breeze(Vec3::new(8.5, 65.5, 8.5));
let registry = &game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.mobs[0].on_ground = true;
for name in [
"minecraft:oak_fence",
"minecraft:cobblestone_wall",
"minecraft:oak_fence_gate",
] {
let state = registry.default_state(name).unwrap();
game.world.set(8, 64, 8, state).unwrap();
assert_eq!(
game.breeze_ground(&game.mobs[0]),
([8, 64, 8], state),
"{name}"
);
}
// The same breeze on the full block's top reads that block.
game.world.set(8, 64, 8, stone).unwrap();
game.mobs[0].position = Vec3::new(8.5, 65.0, 8.5);
assert_eq!(game.breeze_ground(&game.mobs[0]), ([8, 64, 8], stone));
}
}
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff. Show more