Compare commits

..
Author SHA1 Message Date
Horizon agent 536c0f938b Merge branch 'split/cosmetics-fx' into split/cosmetics-settings 2026-11-09 21:13:54 +01:00
Horizon agentandClaude Opus 5.5 e138304d2e Put the aura test's item at the feet
It reused the crown's 18-pixel attach offset, which an aura may not have
(at most 6 pixels above the feet), so the item failed to parse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:10:39 +01:00
Horizon agentandClaude Opus 5.5 398bec3d6e Move the cosmetics settings out of the engine's Extras (spec #14091 step 5a)
The six cosmetics settings (my cosmetics, in single player, other players'
cosmetics, hide others' trails and auras, Horizon logos, show me as Horizon)
leave horizon-client-render's Extras for game/cosmetics/settings.rs:

- their horizon-extras.txt lines go through a settings Store (same keys, so
  players and the launcher's Wardrobe keep their choice; others' line still
  written only once chosen), always registered so a game without cosmetics
  keeps the Wardrobe's lines;
- their rows are a Place::Extras page section, shown while cosmetics are
  offered, in the same Cosmetics group at the end of the Extras page;
- the values live in the per-seat Seat, and the owner flag is read there
  instead of being copied into Extras each frame.

horizon-client-render no longer depends on horizon-cosmetics: the allowed
entry "horizon-client-render -> horizon-cosmetics" is removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:37:10 +01:00
Horizon agentandClaude Opus 5.5 4be9343eca Pass trail corners by value to to_array
Vec3::to_array takes &self, so it can't be handed to array::map directly.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:32:08 +01:00
Horizon agentandClaude Opus 5.5 03fd6705a1 Drop the unused shown binding in Seat::player_layers
worn_of decides what shows since the fx moved onto the hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:27:38 +01:00
Horizon agent a5a60598e4 Merge branch 'split/cosmetics-fx-api' into split/cosmetics-fx 2026-11-09 20:20:28 +01:00
Horizon agent 0fd4e22139 Merge branch 'split/cosmetics-own' into split/cosmetics-fx-api
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:20:25 +01:00
Horizon agent ec19b6320a Merge branch 'split/cosmetics-layers' into split/cosmetics-own 2026-11-09 20:20:01 +01:00
Horizon agent 16e29a2774 Merge branch 'split/cosmetics-draw' into split/cosmetics-layers 2026-11-09 20:20:00 +01:00
Horizon agent e2afd13715 Merge branch 'split/cosmetics-names' into split/cosmetics-draw
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:19:51 +01:00
Horizon agent 00aba43d81 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 20:19:06 +01:00
Horizon agentandClaude Opus 5.5 e893fd3eac Hooks doc: the frame rule is the player methods', not tunnel's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:18:59 +01:00
Horizon agent ee986c3e62 Merge branch 'split/cosmetics-hooks' into split/cosmetics-sprites
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 20:18:48 +01:00
Horizon agent 19aabc08c2 Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:18:27 +01:00
Horizon agentandClaude Opus 5.5 d016678e24 cosmetics test: the prop's root is the item's, whatever its name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:17:44 +01:00
Horizon agent ca3649534d Merge remote-tracking branch 'origin/main' into layers/seat
# Conflicts:
#	crates/horizon-platform/src/lib.rs
2026-11-09 20:15:33 +01:00
Horizon agentandClaude Opus 5.5 3d9df204e9 Cosmetics: companions, trails and auras through the hooks; the game's own path removed (spec #14091 step 2b-iii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:08:05 +01:00
Horizon agentandClaude Opus 5.5 984b7d8635 Hooks: PlayerDraw gets motion, view, looks and particles, and first_person (spec #14091 step 2b-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 20:02:47 +01:00
Horizon agentandClaude Opus 5.5 afa60454b2 Cosmetics: the own body and the inventory picture through PlayerDraw (spec #14091 step 2b-i)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:56:59 +01:00
Horizon agentandClaude Opus 5.5 aa8592fbf9 Cosmetics: their items on the body drawn through PlayerDraw (spec #14091 step 2a-ii)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:52:20 +01:00
Horizon agent 0c77cd5898 Merge branch 'split/cosmetics-names' into split/cosmetics-draw 2026-11-09 19:22:00 +01:00
Horizon agent 4848a9eff5 Merge branch 'split/cosmetics-sprites' into split/cosmetics-names 2026-11-09 19:21:59 +01:00
Horizon agentandClaude Opus 5.5 95b0cb05e7 horizon-hooks test: call SpriteStore::has by its trait
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:58 +01:00
Horizon agentandClaude Opus 5.5 641a5859a2 horizon-platform re-exports the PlayerDraw types
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:34 +01:00
Horizon agentandClaude Opus 5.5 7d17f516e5 Hooks: PlayerDraw, crates draw on player models in the world pass (spec #14091 step 2a-i)
horizon-hooks gains Hooks::player_layers with a plain PlayerDraw (bone
matrices, the frame's ticks, the body pose, sprites, draw of an Arc<Mesh>
with its parts posed) and the Mesh/Quad/Bone/Pass types. The engine
implements it on the GPU skin path (each mesh made a Part once, cached by
its address while a crate holds it) with the vertex path as fallback, and
dispatches it from creatures.rs's world pass one call for one call; the
cosmetics still draw there until 2a-ii.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:15:18 +01:00
Horizon agentandClaude Opus 5.5 8902d94a19 Cosmetics: name tags, the tab mark and the camera pull through the hooks (spec #14091 step 3b)
names.rs, scoreboard.rs and perspective.rs call the engine dispatch
(name_decoration, player_mark, camera) instead of the cosmetics; the
cosmetics answer from their per-seat state: badges become sprites by id
put in the atlas from the frame hook, covered slots read PlayerModel's
gear, the camera pull comes from the emote that pulled it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:07:06 +01:00
Horizon agentandClaude Opus 5.5 ca6140d05e Hooks: name decoration, player-list mark, camera and sprites by id (spec #14091 step 3a)
horizon-hooks gains Hooks::name_decoration (a Copy NameDecoration: mark
rectangles, a badge cut from the crate's sprite, the lift over the body),
player_mark for the player list, camera (a share of the third-person
distance), and sprites by id: Sprites read while drawing, SpriteStore
filled in Hooks::frame (skin slots, hooks/<hex>). PlayerModel carries
gear and invisibility; Frame says whether the world is the window's own.
horizon-platform re-exports them. The engine fills the store; no crate
answers the new hooks yet (step 3b).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:05:10 +01:00
Horizon agentandClaude Opus 5.5 a7f07a08de rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:00:49 +01:00
Horizon agentandClaude Opus 5.5 0e528f7287 Cosmetics: name the per-seat state Seat (the tests' Service is a transport)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:58:25 +01:00
Horizon agent 7ca4fd68bd Merge remote-tracking branch 'origin/layers/seat' into split/cosmetics-hooks 2026-11-09 18:48:40 +01:00
Horizon agent 547b1d4959 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 18:40:43 +01:00
Horizon agentandClaude Opus 5.5 e2918de5aa Seat: a splitscreen guest's profile loads into its own seat
Split::enter loaded a guest's profile before its turn began, so the
crates' settings stores read the guest's lines into P1's seat. The load
now runs on the guest's seat; a new profile is saved on P1's seat (the
stores write P1's values, as the rest of the profile copies P1's) and
its stores are read back into the guest's (Settings::load_stores).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:35 +01:00
Horizon agentandClaude Opus 5.5 e6b0a03270 Cosmetics: emote feed and presence behind frame/tick hooks (spec #14091 step 1)
horizon-hooks gains Hooks::frame and Hooks::tick with a plain player-model
view (PlayerModels, BodyPose moves here), re-exported by horizon-platform.
The cosmetics' client, emote feed, owner check and presence live in a
per-seat static behind those hooks; only a window's game calls them, so
the server thread no longer runs tick_emotes. net::Session gets a serial
that names the connection.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:40:24 +01:00
Ludovic Marie 8f198d9cbd Merge origin/main into split/cosmetics-hooks 2026-11-09 18:30:32 +01:00
Horizon agent bd90908a72 Merge branch 'layers/hud-layers' into layers/seat 2026-11-09 17:44:40 +01:00
Horizon agentandClaude Opus 5.5 a8d445f56c Hooks: seat(), the splitscreen player whose turn it is
Splitscreen players share one process and thread, so a crate's statics
are shared by them. The engine now says whose turn it is
(horizon_hooks::seat(), re-exported in horizon_platform::hooks; 0 for
P1), set where split.rs switches turns and while a joining player's game
starts, so settings stores, pages and HUD layers can keep per-player
state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:09 +01:00
230 changed files with 8090 additions and 4752 deletions

No files matched your search

-2
View File
@@ -198,5 +198,3 @@ new pushes, we add it again.
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
Generated
-1
View File
@@ -1584,7 +1584,6 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
-483
View File
@@ -1,483 +0,0 @@
# Horizon governance
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
stops.
## Contents
1. [Principles](#1-principles)
2. [The levels at a glance](#2-the-levels-at-a-glance)
3. [Each level in detail](#3-each-level-in-detail)
4. [The council and its polls](#4-the-council-and-its-polls)
5. [The selection filter](#5-the-selection-filter)
6. [Theme groups](#6-theme-groups)
7. [Moderation and appeals](#7-moderation-and-appeals)
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
9. [The age rule (18+)](#9-the-age-rule-18)
10. [The agents](#10-the-agents)
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
12. [Money](#12-money)
13. [Transparency](#13-transparency)
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
15. [Changing this document](#15-changing-this-document)
## 1. Principles
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
he counts exactly like every other member.
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
requests, reviews, triage, support answers and days active. A vote then confirms it.
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
not a sanction, and you can come back.
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
the result is published.
- **18+ for roles of trust** (section 9).
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
## 2. The levels at a glance
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
the Horizon Team, for example.
| Level | What it can do | How you get in | How you leave | 18+ |
| --- | --- | --- | --- | --- |
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
like everyone else.
## 3. Each level in detail
### Member
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
### Contributor
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
public horizon-rust repo) shows how much has been merged. Ranks only go up.
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
- **Alone:** their own work.
### Group member
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
nominate a lead or vote in the group's advisory poll.
### 🤝 Support
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
### 🛡️ Moderators and the Chief of Mods
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
same way (section 6, Server and SMP).
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
### 🧭 Group leads
Section 6 says what a lead decides alone and what goes to the council.
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
🧭 lead role carries no server-wide permission.
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
the Horizon Team too.
### 🌌 Horizon Team
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
diff, never merge your own pull request, and use `needs-proof` for anything risky.
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
(`/nightly` and triage commands).
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
one of those roles.
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
### 🏛️ Council
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
starting polls in #polls-and-decisions.
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
release tools (section 11).
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
allow.
- **Any two members together:** an emergency action (section 4.6).
- **Everything else** is decided by poll.
## 4. The council and its polls
### 4.1 Founding council
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
**9** members. It adds a seat only when a 2/3 poll says it needs one.
### 4.2 How a poll works
| Rule | Value |
| --- | --- |
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
| How long | 24 hours. It closes early only if every council member has voted |
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
| Simple majority | More Yes than No |
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
| Tie | Nothing changes |
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
| About yourself | You don't vote, and you don't count in the council's size for that poll |
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
### 4.3 Which threshold applies
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
| Ending a probation early | Money (section 12) |
| Confirming an emergency action | Account handover steps (section 11) |
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
### 4.4 Discussion
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
why. A failed poll can come back after 7 days.
### 4.5 Conflicts of interest
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
### 4.6 Emergencies
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
members act at once**. They may:
- pause releases and the Nightly;
- take a mod down from the registry;
- remove someone's roles or access for now;
- lock or hide channels and content;
- ask the account holder to rotate a secret or a key.
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
adds members, changes rules or spends money.
### 4.7 Removing a council member
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
false age declaration (section 9).
### 4.8 When the council drops below a quorum
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
decisions wait. The Team keeps merging and the leads keep leading.
- **Nobody left:** the Team and the group leads form the interim council in the same way.
## 5. The selection filter
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
measures the work, and only people above a floor can be nominated. The council then decides among them.
### 5.1 What Kira measures
| Signal | What counts | Source |
| --- | --- | --- |
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
| **Verified GitHub** | `/verify-github` done | Kira |
| **18+ declaration** | Made (section 9) | Kira |
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
### 5.2 The floors
A nomination is refused below these floors.
| Role | Floor |
| --- | --- |
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
Horizon and out of reach for people who don't.
### 5.3 The path to a role
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
needs a second from another active group member or a council member.
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
`/apply`, if a council or Team member sponsors them.
- A **council member** can only be nominated by a council member.
- **Moderators** apply with `/apply`.
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
is short.
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
majority) ended it earlier.
### 5.4 Exceptions
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
### 5.5 Inactivity
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
holder or an org owner removes GitHub access when the Team role ends.
| Role | What counts as activity | Warning | Steps down |
| --- | --- | --- | --- |
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
| Horizon Team | A merge or a review | Day 45 | Day 60 |
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
| Support | An answer in #horizon-support | Day 21 | Day 30 |
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
count. A lead names someone from the group to cover for them.
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
floor and is nominated, both go on the same poll.
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
is always possible (section 4.7).
## 6. Theme groups
### 6.1 The groups
| Group | Covers | GitHub | Discord |
| --- | --- | --- | --- |
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
the council decides.
### 6.2 Leads per group
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
preferred: it spreads the work.
### 6.3 What a lead decides alone
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
the design system:
- the group's priorities and the order of its roadmap items;
- which issues are `good first issue`, and triage of the group's issues;
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
take that decision to the council;
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
- asking the Team for reviews, adding `needs-proof`, and nominating people.
### 6.4 What goes to the council
- the rules, money, accounts and secrets;
- who is on the council, the Team or the mods;
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
- breaking a public promise: the mod API, server compatibility, save and file formats;
- disputes between groups;
- anything involving Mojang, Microsoft or the law;
- speaking for the whole project outside the group's topic.
### 6.5 A group without a lead
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
nobody is appointed just to fill the seat.
## 7. Moderation and appeals
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
change the rules (2/3).
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
or the Chief of Mods, answers within 72 hours.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
Members who were involved don't vote. The answer is final for 90 days.
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
(simple majority). The council removes the Chief of Mods the same way.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
| Case | What happens |
| --- | --- |
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
| Someone holds a role and is under 18 | Section 9 |
| The council can't reach quorum | Section 4.8 |
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
## 9. The age rule (18+)
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
18 are welcome and valued. They just can't hold the roles above.
**How it's checked, without collecting ID:**
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
ID, a photo, or any outside age-checking service's result.
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
paused.
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
within 72 hours in #council.
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
any other step-down.
## 10. The agents
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
members, and the standing rules in this document and the project's docs.
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
request, is that person using their role. The agents help, within the role's powers.
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
(section 13). A message saying "the council decided", or a screenshot, is not enough.
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
project's.
## 11. Accounts and the handover path
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
unable to act, and never puts an account that is tied to a person into anyone else's hands.
| Account | Today | Can it be shared? | Plan |
| --- | --- | --- | --- |
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
and the founder carries it out.
## 12. Money
*Placeholder: the options (Open Collective and others) are being researched.*
- The council approves every expense by a **2/3 poll**.
- **Every income and every expense is public.**
- Nobody votes on money paid to themselves.
## 13. Transparency
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
For polls that aren't about a person, each member's vote is shown once the poll closes.
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
#polls-and-decisions.
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
the Chief of Mods, the moderators and Support.
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
## 14. Rules that are never voted on
These are Horizon's conditions for existing, not choices. No poll can change them:
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
- **No gambling**, in any form, including with in-game currency.
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
- **The law**, and the terms of service of Discord and GitHub.
## 15. Changing this document
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
(section 5.2) once a year, or sooner if they turn out wrong.
-2
View File
@@ -135,8 +135,6 @@ python3 tools/pr_ready.py
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
## Project status
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
@@ -1441,16 +1441,6 @@ pub enum Rumble {
},
Totem,
}
impl From<crate::input_source::Feedback> for Rumble {
fn from(event: crate::input_source::Feedback) -> Self {
use crate::input_source::Feedback;
match event {
Feedback::BlockBroken => Rumble::BlockBroken,
Feedback::Explosion { distance, radius } => Rumble::Explosion { distance, radius },
Feedback::Totem => Rumble::Totem,
}
}
}
/// A vibration: the heavy (low-frequency) and light motors, 0..1, and how
/// long it lasts.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -1,15 +0,0 @@
//! The engine's side of the input source (docs/layers.md, "Controllers, splitscreen, replays and
//! UI pages"): what gameplay tells a device beyond the keyboard and mouse. Gameplay names these
//! types, never a controller's; the controller code answers.
/// Something that happened to the player that a device may let them feel (a controller's
/// vibration). The device decides how, or whether.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Feedback {
/// The player broke a block.
BlockBroken,
/// An explosion this far away (blocks) with this power.
Explosion { distance: f64, radius: f32 },
/// A totem of undying saved the player.
Totem,
}
-1
View File
@@ -9,7 +9,6 @@ pub mod gamepad;
pub mod gameplay_guide;
pub mod held;
pub mod input;
pub mod input_source;
pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
-5
View File
@@ -9,7 +9,6 @@ description = "The client's world data, rendering and screens: chunks, meshing,
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
@@ -76,10 +75,6 @@ wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
# horizon-client's `horizon-extras`: asking the device for hardware ray tracing on request
# (`gfx/ray_query.rs`, `HORIZON_RAY_TRACING=1`). Without it the device is requested as without
# the variable.
horizon-extras = []
[lints]
workspace = true
@@ -1,158 +0,0 @@
//! Audio occlusion, attenuation and mixing benchmark.
//! Run with: `cargo run --profile fast -p horizon-client-render --example audio_bench`
use horizon_client_render::{
audio::Audio,
sound_engine::{MobSound, SoundInstance, Sounds, TickContext},
};
use std::{path::Path, time::Instant};
fn main() {
println!("=== Horizon Audio Attenuation & Mixing Benchmark ===");
let mut audio = Audio::silent(Path::new("/nonexistent"));
audio.engine.loaded = true;
audio.backend_live_for_bench();
audio.record_heard = false;
// Register a sound event with attenuation distance 16
audio.engine.registry.add_file(
"minecraft",
br#"{"bench.loop":{"attenuation_distance":16,"sounds":[{"name":"bench/loop","stream":false}]}}"#,
&|_, _| true,
);
// Insert 1 second of audio into cache (48000 mono samples)
let samples: Vec<f32> = (0..48_000)
.map(|i| ((i as f32) * 440.0 * 2.0 * std::f32::consts::PI / 48000.0).sin() * 0.5)
.collect();
audio.insert_cached_sound("minecraft:bench/loop", samples, 48_000, 1);
// 1. Benchmark set_listener (called every client render frame)
let listener_iterations = 200_000;
let start = Instant::now();
for i in 0..listener_iterations {
let angle = (i as f32) * 0.01;
audio.set_listener(
[
100.0 + (angle.cos() as f64) * 5.0,
64.0,
200.0 + (angle.sin() as f64) * 5.0,
],
angle,
0.0,
);
}
let listener_elapsed = start.elapsed();
println!(
"1. set_listener moving (render frame): {:?} for {} calls ({:.2} ns/call)",
listener_elapsed,
listener_iterations,
(listener_elapsed.as_nanos() as f64) / (listener_iterations as f64)
);
// 1b. Benchmark set_listener stationary (lock elimination early-out)
let stationary_iterations = 200_000;
let start = Instant::now();
for _ in 0..stationary_iterations {
audio.set_listener([100.0, 64.0, 200.0], 0.0, 0.0);
}
let stationary_elapsed = start.elapsed();
println!(
"1b. set_listener stationary (lock early-out): {:?} for {} calls ({:.2} ns/call)",
stationary_elapsed,
stationary_iterations,
(stationary_elapsed.as_nanos() as f64) / (stationary_iterations as f64)
);
// 2. Play 128 sounds in the world at various distances from listener at [100.0, 64.0, 200.0]
// 64 sounds within attenuation distance (<16 blocks)
// 64 sounds outside attenuation distance (>16 blocks)
for i in 0..128 {
let dist = if i < 64 {
(i as f64) * 0.24 // 0 to 15.1 blocks (audible)
} else {
17.0 + ((i - 64) as f64) * 1.5 // 17 to 111.5 blocks (out of attenuation range)
};
let pos = [100.0 + dist, 64.0, 200.0];
let instance = SoundInstance::new(
"minecraft:bench.loop",
crate_sound_source(i),
1.0,
1.0,
horizon_core::random::LegacyRandom::new(i as i64),
true, // looping
0,
horizon_client_render::sound_engine::Attenuation::Linear,
pos,
false,
);
audio.play_instance(instance, false);
}
// 3. Benchmark mix_buffer (5000 buffers of 1024 stereo frames @ 48kHz = ~106 seconds of audio mixing)
let mut buffer = vec![0.0f32; 1024 * 2];
let mix_iterations = 5_000;
let start = Instant::now();
for _ in 0..mix_iterations {
audio.mix_buffer(&mut buffer, 2, 48_000);
}
let mix_elapsed = start.elapsed();
println!(
"2. mix_buffer (128 active channels, 1024 stereo frames): {:?} for {} buffers ({:.2} µs/buffer)",
mix_elapsed,
mix_iterations,
(mix_elapsed.as_micros() as f64) / (mix_iterations as f64)
);
// 4. Benchmark SoundEngine::tick with ticking mob sounds
let mut mobs = Vec::new();
for i in 0..64 {
mobs.push(MobSound {
id: 2000 + i,
position: [100.0 + (i as f64) * 0.5, 64.0, 200.0 + (i as f64) * 0.5],
speed: 0.25,
baby: false,
angry: i % 2 == 0,
silent: false,
attack_scale: 0.5,
sniffer_digging: false,
});
}
let tick_ctx = TickContext {
player_underwater: false,
player_removed: false,
vehicle: None,
carts: Vec::new(),
mobs: mobs.clone(),
frozen: false,
player_position: [100.0, 64.0, 200.0],
player_speed_sqr: 0.1,
player_fall_flying: false,
ridden: None,
camera_position: [100.0, 64.0, 200.0],
};
let tick_iterations = 10_000;
let start = Instant::now();
for _ in 0..tick_iterations {
audio.tick(false, &tick_ctx);
}
let tick_elapsed = start.elapsed();
println!(
"3. SoundEngine::tick (64 channels + ticking): {:?} for {} ticks ({:.2} µs/tick)",
tick_elapsed,
tick_iterations,
(tick_elapsed.as_micros() as f64) / (tick_iterations as f64)
);
}
fn crate_sound_source(i: u32) -> usize {
match i % 6 {
0 => 0, // master
1 => 1, // music
2 => 2, // records
3 => 3, // weather
4 => 4, // blocks
_ => 5, // hostile
}
}
+2 -2
View File
@@ -382,8 +382,8 @@ impl RenderAssets {
};
// After the connected tiles: a tile identical to a block shares its layer, and the
// overlays skip shared layers.
if draws && let Some(suffix) = crate::pack_hooks::formats().emissive_suffix(root) {
emissive = crate::native_emissive::load_overlays(root, &suffix, &mut sprites);
if draws {
emissive = crate::native_emissive::load_optifine(root, &mut sprites);
}
sprites.save_disk_cache();
if draws {
@@ -233,11 +233,11 @@ pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
}
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
let rel = format!("assets/{namespace}/{directory}/{path}.png");
// No `.`, `..` or empty segment, and no `\` (a separator on Windows, where `..\..` climbs).
let stays = rel
.split('/')
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'));
root.join(if stays { &rel } else { NOWHERE })
root.join(if horizon_optifine::paths::valid(&rel) {
&rel
} else {
NOWHERE
})
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
+52 -166
View File
@@ -478,7 +478,6 @@ pub struct Audio {
pub record_heard: bool,
pub listener: [f64; 3],
pub listener_yaw: f32,
pub listener_pitch: f32,
pub master: f32,
/// Seeds of positional plays (the client level random's `nextLong`).
random: LegacyRandom,
@@ -723,29 +722,6 @@ impl Backend for MixerBackend {
fn set_unmuffled(&mut self, channel: ChannelId, unmuffled: bool) {
self.with(channel, |c| c.unmuffled = unmuffled);
}
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.gain = volume;
c.pitch = pitch;
c.at = [position[0] as f32, position[1] as f32, position[2] as f32];
}
}
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
if let Ok(m) = self.mixer.lock() {
for &(h, c) in live {
if m.channels
.get(&c)
.is_none_or(|ch| ch.state == State::Stopped)
{
stopped.push(h);
}
}
} else {
stopped.extend(live.iter().map(|&(h, _)| h));
}
}
/// `getCompleteBuffer` (cached, else decoded on the decoder thread) or
/// `getStream` (decoded on a thread of its own), then attach and
/// `alSourcePlay`. A sound that cannot load leaves its source initial,
@@ -846,24 +822,18 @@ impl Backend for MixerBackend {
/// pinned client's) rendered through `ALC_SOFT_loopback` within 1e-5.
fn stereo_gains(right: f32, up: f32, forward: f32) -> [f32; 2] {
let length = (right * right + up * up + forward * forward).sqrt();
stereo_gains_with_length(right, up, forward, length)
}
#[inline]
fn stereo_gains_with_length(right: f32, up: f32, forward: f32, length: f32) -> [f32; 2] {
if length <= f32::EPSILON {
return stereo_gains_with_length(0.0, 0.0, 1.0, 1.0);
return stereo_gains(0.0, 0.0, 1.0);
}
let u = (up / length).clamp(-1.0, 1.0);
let cos_elev = (1.0 - u * u).max(0.0).sqrt();
let elevation = (up / length).clamp(-1.0, 1.0).asin();
let mut azimuth = right.atan2(forward);
if azimuth.abs() < std::f32::consts::FRAC_PI_2 {
azimuth = (azimuth.abs() * 1.5)
.min(std::f32::consts::FRAC_PI_2)
.copysign(azimuth);
}
let (sin_az, cos_az) = azimuth.sin_cos();
let side = sin_az * cos_elev;
let front = cos_az * cos_elev * 0.095_660_8;
let side = azimuth.sin() * elevation.cos();
let front = azimuth.cos() * elevation.cos() * 0.095_660_8;
[0.5 - 0.5 * side + front, 0.5 + 0.5 * side + front]
}
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
@@ -883,10 +853,16 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// and OpenAL's right = forward x up.
let basis = |yaw: f32, pitch: f32| {
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
let (yaw_sin, yaw_cos) = yaw.sin_cos();
let (pitch_sin, pitch_cos) = pitch.sin_cos();
let forward = [-yaw_sin * pitch_cos, -pitch_sin, yaw_cos * pitch_cos];
let up = [-yaw_sin * pitch_sin, pitch_cos, yaw_cos * pitch_sin];
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
@@ -920,7 +896,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
// Gains and pans per source for this buffer.
let mut voices = std::mem::take(&mut mixer.voices);
voices.clear();
for (id, c) in &mut mixer.channels {
for (id, c) in &mixer.channels {
let Some((_, source_rate, source_channels)) = &c.samples else {
continue;
};
@@ -931,7 +907,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
if c.state != State::Playing {
continue;
}
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
let away = |listener: [f64; 3]| {
[
@@ -940,56 +915,45 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
at[2] - listener[2],
]
};
let length_sq = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
let camera = if !shared {
let cam = &mixer.cameras[0];
if c.relative || cam.2 == c.world {
Some((cam.0, cam.1))
} else {
None
}
} else {
mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length_sq(away(a.0)).total_cmp(&length_sq(away(b.0))))
.map(|camera| (camera.0, camera.1))
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = mixer
.cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
.map(|camera| (camera.0, camera.1));
// Unheard sources still advance: returning to a world must not resume stale sounds.
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
let d = if c.relative { at } else { away(listener) };
let d_sqr = length_sq(d);
let (gain, [left, right]) = if camera.is_none() {
(0.0, [0.0, 0.0])
} else if direct {
let gain = c.gain.clamp(0.0, 1.0);
(gain, [gain, gain])
} else {
let mut zero = false;
if let Some(max) = c.linear {
let max_d = max as f64;
if max <= 0.0 || (!c.relative && d_sqr >= max_d * max_d) {
zero = true;
}
}
if zero {
(0.0, [0.0, 0.0])
} else {
let distance = d_sqr.sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
let [mut left, mut right] = if c.relative {
stereo_gains_with_length(d[0] as f32, d[1] as f32, -d[2] as f32, distance)
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0)
* if direct {
1.0
} else {
stereo_gains_with_length(dot(d, right), dot(d, up), dot(d, forward), distance)
};
if shared {
let middle = (left + right) * 0.5;
left = middle + (left - middle) * 0.5;
right = middle + (right - middle) * 0.5;
distance_gain(c.linear, distance)
}
(gain, [left * gain, right * gain])
} else {
0.0
};
let [left, right] = if direct {
[gain, gain]
} else {
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
[left * gain, right * gain]
};
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
@@ -1010,40 +974,6 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
};
let source_channels = source_channels as usize;
let frames = samples.len() / source_channels;
let inaudible = if channels >= 2 {
left == 0.0 && right == 0.0
} else {
gain == 0.0
};
if inaudible {
let buffer_frames = data.len() / channels;
// As the loop below: a sound of one frame or none stops, looping or not.
if !c.looping || frames <= 1 {
// `frames` may be 0: no `frames - 1` in usize. A step of 0 never
// reaches the end, as in the loop below.
let needed = frames as f64 - 1.0 - c.position;
if needed <= 0.0 {
c.state = State::Stopped;
} else if step > 0.0 {
let steps_to_stop = needed / step;
if (buffer_frames as f64) >= steps_to_stop {
c.state = State::Stopped;
} else {
c.position += step * (buffer_frames as f64);
}
}
} else {
for _ in 0..buffer_frames {
let i = c.position as usize;
if i + 1 >= frames {
c.position = 0.0;
}
c.position += step;
}
}
continue;
}
let out: &mut [f32] = if muffling && !c.unmuffled {
&mut bus
} else {
@@ -1062,8 +992,8 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
}
let t = (c.position - i as f64) as f32;
if source_channels > 1 {
let l = samples[i * 2] + (samples[(i + 1) * 2] - samples[i * 2]) * t;
let r = samples[i * 2 + 1] + (samples[(i + 1) * 2 + 1] - samples[i * 2 + 1]) * t;
let l = samples[i * 2] * (1.0 - t) + samples[(i + 1) * 2] * t;
let r = samples[i * 2 + 1] * (1.0 - t) + samples[(i + 1) * 2 + 1] * t;
if channels >= 2 {
frame[0] += l * left;
frame[1] += r * right;
@@ -1071,7 +1001,7 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
frame[0] += (l + r) * 0.5 * gain;
}
} else {
let s = samples[i] + (samples[i + 1] - samples[i]) * t;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
@@ -1482,7 +1412,6 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: 1.0,
random: crate::random_source::create(),
source_override: None,
@@ -1606,12 +1535,8 @@ impl Audio {
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
if self.listener == position && self.listener_yaw == yaw && self.listener_pitch == pitch {
return;
}
self.listener = position;
self.listener_yaw = yaw;
self.listener_pitch = pitch;
if let Ok(mut m) = self.backend.mixer.lock() {
if self.backend.owner == 0 {
m.listener = position;
@@ -1651,7 +1576,6 @@ impl Audio {
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
listener_pitch: 0.0,
master: self.master,
random: crate::random_source::create(),
source_override: None,
@@ -2185,25 +2109,6 @@ impl Audio {
})
.collect()
}
/// Mixes one buffer of audio into `data` (channels: 1 or 2, rate in Hz).
pub fn mix_buffer(&mut self, data: &mut [f32], channels: usize, rate: u32) {
if let Ok(mut m) = self.backend.mixer.lock() {
mix(&mut m, data, channels, rate);
}
}
#[doc(hidden)]
pub fn insert_cached_sound(&mut self, key: &str, samples: Vec<f32>, rate: u32, channels: u16) {
if let Ok(mut c) = self.backend.cache.lock() {
c.insert(
key.to_owned(),
Some((std::sync::Arc::new(samples), rate, channels)),
);
}
}
#[doc(hidden)]
pub fn backend_live_for_bench(&mut self) {
self.backend.live = true;
}
}
impl Sounds for Audio {
fn engine(&mut self) -> &mut SoundEngine {
@@ -2708,25 +2613,6 @@ mod tests {
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
}
/// A sound of one frame or none, heard or not, looping or not, stops at its first
/// buffer as the mixing loop stops it: none stays playing (and holds its channel).
#[test]
fn sounds_too_short_to_mix_stop_even_unheard() {
for (samples, looping) in [(vec![], false), (vec![1.0], false), (vec![1.0], true)] {
let mut mixer = Mixer::default();
let mut source = channel(samples.clone(), None, [0.0; 3], looping);
source.gain = 0.0;
mixer.channels.insert(1, source);
let mut data = [0.0; 4];
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(
mixer.channels[&1].state,
State::Stopped,
"{samples:?} {looping}"
);
}
}
#[test]
fn variants_of_one_sound_are_the_same_sound() {
assert_eq!(
@@ -569,25 +569,6 @@ pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn field(
&mut self,
root: horizon_polytone::expr::Root,
field: horizon_polytone::expr::Field,
) -> Option<horizon_polytone::expr::Value> {
use horizon_polytone::expr::Field as F;
if root != horizon_polytone::expr::Root::Object {
return None;
}
Some(horizon_polytone::expr::Value::Num(match field {
F::X => f64::from(self.at[0]),
F::Y => f64::from(self.at[1]),
F::Z => f64::from(self.at[2]),
F::Temperature => f64::from(self.biome.temperature),
F::Downfall => f64::from(self.biome.downfall),
_ => return None,
}))
}
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
+9 -216
View File
@@ -168,24 +168,6 @@ pub struct Extras {
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled), the lines `horizon_cosmetics::prefs`
/// shares with the launcher's Wardrobe: Show my cosmetics (the account's, sent to the service;
/// on by default), how much of other players' to show (no line until chosen:
/// `effective_others`), and whether their trails and auras are hidden (off by default).
pub cosmetics_mine: bool,
pub cosmetics_others: Option<horizon_cosmetics::prefs::Others>,
pub cosmetics_hide_fx: bool,
/// My cosmetics in single player too (`cosmeticsSolo`, off by default): the window's own server
/// names the player by their offline profile, which the service doesn't know, so their own
/// cosmetics and name tag style show only with this on.
pub cosmetics_solo: bool,
/// Whether the signed-in account owns a cosmetic, as the service said (never saved; the game
/// sets it): with no choice made, others' cosmetics show only then.
pub cosmetics_owner: bool,
/// Horizon's mark before the names of other Horizon players (name tags, player list), and
/// whether the service may say this player plays Horizon: both on by default.
pub horizon_marks: bool,
pub horizon_listed: bool,
/// 3D skin layers (`threeDSkinLayers`, Options → Skin Customization): each
/// painted texel of a player's outer layer drawn as a slab
/// (`horizon_client::mobs::skin_layers`).
@@ -241,13 +223,6 @@ impl Default for Extras {
wheel_cycles_settings: false,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: None,
cosmetics_hide_fx: false,
cosmetics_solo: false,
cosmetics_owner: false,
horizon_marks: true,
horizon_listed: true,
three_d_skin_layers: false,
pvp: Default::default(),
}
@@ -257,11 +232,6 @@ fn bool_text(v: bool) -> &'static str {
if v { "true" } else { "false" }
}
impl Extras {
/// How much of other players' cosmetics shows: the player's choice, else all of it for an
/// account that owns a cosmetic and none for any other (a vanilla game).
pub fn effective_others(&self) -> horizon_cosmetics::prefs::Others {
horizon_cosmetics::prefs::Others::effective(self.cosmetics_others, self.cosmetics_owner)
}
pub fn load(dir: &Path) -> Self {
fs::read_to_string(dir.join(FILE))
.map(|t| Self::parse(&t))
@@ -357,15 +327,6 @@ impl Extras {
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => {
e.cosmetics_others =
horizon_cosmetics::prefs::Others::parse(value).or(e.cosmetics_others)
}
"cosmeticsHideFx" => e.cosmetics_hide_fx = flag(e.cosmetics_hide_fx),
"cosmeticsSolo" => e.cosmetics_solo = flag(e.cosmetics_solo),
horizon_cosmetics::prefs::MARKS => e.horizon_marks = flag(e.horizon_marks),
horizon_cosmetics::prefs::LISTED => e.horizon_listed = flag(e.horizon_listed),
SKIN_LAYERS_KEY => e.three_d_skin_layers = flag(e.three_d_skin_layers),
key => {
e.pvp.parse_line(key, value);
@@ -376,7 +337,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:{}\ndiscordPresence:{}\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:{}\n{}:{}\n",
bool_text(self.pack_browser),
bool_text(self.pregen),
bool_text(self.fullbright),
@@ -426,16 +387,6 @@ impl Extras {
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
// Written once the player chose.
self.cosmetics_others
.map_or(String::new(), |o| format!("cosmeticsOthers:{}\n", o.name())),
bool_text(self.cosmetics_hide_fx),
bool_text(self.cosmetics_solo),
horizon_cosmetics::prefs::MARKS,
bool_text(self.horizon_marks),
horizon_cosmetics::prefs::LISTED,
bool_text(self.horizon_listed),
SKIN_LAYERS_KEY,
bool_text(self.three_d_skin_layers),
);
@@ -623,13 +574,6 @@ pub enum Item {
/// The Select Resource Packs screen's Add button (Modrinth).
PackBrowser,
Pregen,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
CosmeticsHideFx,
CosmeticsSolo,
HorizonMarks,
HorizonMe,
/// On the PvP & HUD page (`pvp::rows`).
Pvp(crate::pvp::Opt),
}
@@ -637,8 +581,10 @@ pub enum Item {
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
// Fullbright, Dynamic lights and Hide block-break particles change the picture: they
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here.
let mut rows = vec![
// are on the graphics screen's Quality page (`graphics_ui::rows`), not here. The Cosmetics
// group comes last, from the cosmetics' own section (`pages::Place::Extras`) while they are
// offered.
vec![
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("horizon.extras.header.inventory"),
@@ -659,19 +605,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
Ok(PackBrowser),
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsSolo),
Ok(CosmeticsOthers),
Ok(CosmeticsHideFx),
Ok(HorizonMarks),
Ok(HorizonMe),
]);
}
rows
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -698,12 +632,6 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::PackBrowser => "horizon.extras.pack_browser",
Item::Pregen => "horizon.extras.pregen",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo",
Item::HorizonMarks => "horizon.extras.horizon_marks",
Item::HorizonMe => "horizon.extras.horizon_me",
Item::Pvp(o) => o.caption_key(),
}
}
@@ -731,12 +659,6 @@ impl Item {
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_list_settings.tooltip",
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
Item::Pregen => "horizon.extras.pregen.tooltip",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
Item::CosmeticsHideFx => "horizon.extras.cosmetics_hide_fx.tooltip",
Item::CosmeticsSolo => "horizon.extras.cosmetics_solo.tooltip",
Item::HorizonMarks => "horizon.extras.horizon_marks.tooltip",
Item::HorizonMe => "horizon.extras.horizon_me.tooltip",
Item::Pvp(o) => o.description_key(),
}
}
@@ -773,12 +695,6 @@ impl Item {
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::PackBrowser => "Resource pack browser",
Item::Pregen => "Chunk pregeneration",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
Item::CosmeticsHideFx => "Hide other players' trails and auras",
Item::CosmeticsSolo => "In single player",
Item::HorizonMarks => "Horizon logos",
Item::HorizonMe => "Show me as Horizon",
Item::Pvp(o) => o.caption(),
}
}
@@ -793,9 +709,7 @@ impl Item {
Pvp(o) => return o.parent().map(Pvp),
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
| CosmeticsOthers | CosmeticsHideFx | CosmeticsSolo | HorizonMarks | HorizonMe
| Pregen => {
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | Pregen => {
return None;
}
})
@@ -856,24 +770,6 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Hidden: every Horizon player sees you without your cosmetics, you included. The same switch as the launcher's Wardrobe."
}
Item::CosmeticsOthers => {
"What other Horizon players wear: everything, only their name tag styles, or nothing. Hidden unless you own cosmetics, until you choose. Hidden, the game never asks what they wear."
}
Item::CosmeticsHideFx => {
"Other players' trails and auras stay hidden; their other cosmetics still show. Off by default."
}
Item::CosmeticsSolo => {
"Show your cosmetics and your name tag's badge in your single-player worlds too (your name tag with Own name tag). Off: only on servers."
}
Item::HorizonMarks => {
"Show the Horizon logo next to Horizon players: a small logo before the names of other players who use Horizon, above their heads and in the player list. Only Horizon players see it: servers receive nothing."
}
Item::HorizonMe => {
"Show me as a Horizon player: other Horizon players see the logo by your name. On, the service answers anyone who asks about you that you played Horizon in the last 3 hours; off, it never reports you."
}
Item::Pvp(o) => o.description(),
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
@@ -900,12 +796,6 @@ impl Item {
| Item::Splitscreen
| Item::WheelCyclesSettings
| Item::PackBrowser => Some(Cost::None),
Item::CosmeticsMine
| Item::CosmeticsOthers
| Item::CosmeticsHideFx
| Item::CosmeticsSolo
| Item::HorizonMarks
| Item::HorizonMe => Some(Cost::Low),
_ => None,
}
}
@@ -932,10 +822,6 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser,
Item::Pregen => e.pregen,
Item::CosmeticsHideFx => e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks,
Item::HorizonMe => e.horizon_listed,
Item::Pvp(o) => return o.switch(&e.pvp),
_ => return None,
})
@@ -945,30 +831,14 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
Item::Pvp(o) if o.switch(&e.pvp).is_none() => o.value(&e.pvp).unwrap_or_default(),
Item::CosmeticsMine => match e.cosmetics_mine {
true => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
false => crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden"),
},
Item::CosmeticsOthers => {
use horizon_cosmetics::prefs::Others;
match e.effective_others() {
Others::Shown => crate::lang::get_or("horizon.extras.cosmetics.shown", "Shown"),
Others::NameTags => {
crate::lang::get_or("horizon.extras.cosmetics.name_tags", "Name tags only")
}
Others::Hidden => {
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
}
}
}
other => match other.switch(e) {
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
/// A click (`back` for the right button: it turns the three-way cosmetics button the other
/// way; every other item left is a switch, so both flip it). The sort key binds instead.
/// A click (`back` for the right button: a PvP & HUD choice steps back; a switch flips
/// either way). The sort key binds instead.
pub fn cycle(self, e: &mut Extras, back: bool) {
match self {
Item::Fullbright => e.fullbright = !e.fullbright,
@@ -991,16 +861,6 @@ impl Item {
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::PackBrowser => e.pack_browser = !e.pack_browser,
Item::Pregen => e.pregen = !e.pregen,
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => {
// From what the button shows; the result is the player's choice from now on.
let o = e.effective_others();
e.cosmetics_others = Some(if back { o.next().next() } else { o.next() });
}
Item::CosmeticsHideFx => e.cosmetics_hide_fx = !e.cosmetics_hide_fx,
Item::CosmeticsSolo => e.cosmetics_solo = !e.cosmetics_solo,
Item::HorizonMarks => e.horizon_marks = !e.horizon_marks,
Item::HorizonMe => e.horizon_listed = !e.horizon_listed,
Item::Pvp(o) => o.cycle(&mut e.pvp, back),
Item::SortKey => {}
}
@@ -1019,73 +879,6 @@ impl Item {
mod tests {
use super::*;
#[test]
fn other_players_cosmetics_cycle_three_ways_and_read_the_old_switch() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras {
cosmetics_owner: true,
..Extras::default()
};
assert_eq!(
(e.effective_others(), e.cosmetics_hide_fx, e.cosmetics_mine),
(Others::Shown, false, true)
);
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Hidden));
// The right button goes back.
Item::CosmeticsOthers.cycle(&mut e, true);
assert_eq!(e.cosmetics_others, Some(Others::NameTags));
// Ownership is the service's, never written.
assert_eq!(
Extras::parse(&e.serialize()),
Extras {
cosmetics_owner: false,
..e.clone()
}
);
// The launcher's Wardrobe writes the same lines.
assert_eq!(
horizon_cosmetics::prefs::Prefs::parse(&e.serialize()).others,
Some(Others::NameTags)
);
// Before this, the line was on or off.
assert_eq!(
Extras::parse("cosmeticsOthers:false").cosmetics_others,
Some(Others::Hidden)
);
}
#[test]
fn others_cosmetics_are_off_for_a_player_who_owns_none_until_chosen() {
use horizon_cosmetics::prefs::Others;
let mut e = Extras::default();
// Nothing chosen, nothing owned: the button shows Hidden and no line is written.
assert_eq!(e.effective_others(), Others::Hidden);
assert_eq!(
Item::CosmeticsOthers.value(&e),
crate::lang::get_or("horizon.extras.cosmetics.hidden", "Hidden")
);
assert!(!e.serialize().contains("cosmeticsOthers"));
// The service says the account owns one: shown, still not a choice.
e.cosmetics_owner = true;
assert_eq!(e.effective_others(), Others::Shown);
assert!(!e.serialize().contains("cosmeticsOthers"));
// A non-owner pressing the button goes on from Hidden, and that choice is kept.
e.cosmetics_owner = false;
Item::CosmeticsOthers.cycle(&mut e, false);
assert_eq!(e.cosmetics_others, Some(Others::Shown));
let saved = Extras::parse(&e.serialize());
assert_eq!(saved.effective_others(), Others::Shown);
// An owner who chose Hidden keeps it.
let hidden = Extras {
cosmetics_owner: true,
..Extras::parse("cosmeticsOthers:hidden\n")
};
assert_eq!(hidden.effective_others(), Others::Hidden);
}
#[test]
fn a_saved_wheel_setting_from_before_the_default_changed_is_dropped() {
assert!(!Extras::parse("wheelCyclesSettings:true\n").wheel_cycles_settings);
+2 -19
View File
@@ -202,14 +202,13 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
/// The rows read the extras, the mods and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
shell::stamp_of(&(&s.extras, &s.mods, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -317,22 +316,6 @@ mod tests {
assert_ne!(s.extras.mouse_tweaks_wheel, before);
}
#[test]
fn the_three_way_cosmetics_row_turns_both_ways() {
use horizon_cosmetics::prefs::Others;
let mut s = Settings::default();
let mut screen = ExtrasScreen;
let row = ExtraRow::Item(Item::CosmeticsOthers);
// Not chosen, nothing owned: it shows Hidden, and forward wraps to Shown.
assert_eq!(s.extras.effective_others(), Others::Hidden);
screen.press(&mut s, row, false);
assert_eq!(s.extras.cosmetics_others, Some(Others::Shown));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::Hidden));
screen.press(&mut s, row, true);
assert_eq!(s.extras.cosmetics_others, Some(Others::NameTags));
}
/// The sort key binds instead of cycling; the wheel leaves it be.
#[test]
fn the_key_row_waits_for_a_key() {
+4 -4
View File
@@ -231,8 +231,8 @@ pub struct FogInputs<'a> {
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
/// The packs' Polytone `environment` fog settings.
pub environment: horizon_polytone::colors::Environment,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -581,7 +581,7 @@ mod tests {
let tables = FogTables {
water_fog_end: vec![1.0],
};
let environment = crate::pack_palette::Environment {
let environment = horizon_polytone::colors::Environment {
powder_snow_fog: Some(0xFF0000),
blindness_fog_distance: 40.0,
water_fog_brightening: 0.0,
@@ -613,7 +613,7 @@ mod tests {
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: crate::pack_palette::Environment,
environment: horizon_polytone::colors::Environment,
) -> FogInputs<'a> {
FogInputs {
environment,
+61 -14
View File
@@ -239,7 +239,6 @@ mod reuse;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
mod off_page;
pub mod submit;
mod sunrise_fan;
mod timing;
@@ -251,7 +250,6 @@ mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
use off_page::move_off_page;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -728,6 +726,62 @@ impl Part {
}
}
}
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
/// Starts moving a page of a large arena far larger than its ranges in use
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
@@ -10062,11 +10116,7 @@ impl Renderer {
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
// after the world pass, as `GameRenderer.render` hands them the main target's.
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
let keep_depth = pack_reads_world
|| post_reads_depth
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth
@@ -13664,7 +13714,6 @@ mod tests {
Some(Reported {
paper_white: 240,
peak: 998,
peak_guessed: true,
})
);
assert_eq!(
@@ -13672,7 +13721,6 @@ mod tests {
Some(Reported {
paper_white: 480,
peak: 480,
peak_guessed: true,
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
@@ -13681,7 +13729,6 @@ mod tests {
Some(Reported {
paper_white: 456,
peak: 456,
peak_guessed: true,
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
@@ -15372,9 +15419,11 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
});
}
/// 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`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
/// white level the brightness slider sets), which is HDR output's scale there: SDR
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
/// the settings' ranges. The OS's figures often come from the display's EDID and may
/// run optimistic; the player's own value always wins.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -15386,7 +15435,6 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
peak_guessed: true,
})
}
@@ -15422,7 +15470,6 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
peak_guessed: false,
}
}
@@ -1,59 +0,0 @@
//! `move_off_page`, moved out of `gfx.rs` unchanged.
use super::*;
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
pub(super) fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
@@ -300,7 +300,6 @@ pub(crate) mod tests {
luminance: Some(Reported {
paper_white: 400,
peak: 1000,
peak_guessed: false,
}),
hdr: system,
},
@@ -651,7 +650,6 @@ pub(crate) mod tests {
window_reading.luminance = Some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
});
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
assert_eq!(
@@ -549,7 +549,6 @@ mod tests {
reading.luminance = Some(Reported {
paper_white: 250,
peak: 600,
peak_guessed: false,
});
// 150 nits under the reported paper white of 250: 0.6 of it.
settings.hdr_ui_nits = 150;
@@ -585,7 +584,6 @@ mod tests {
reading.luminance = Some(Reported {
paper_white,
peak: 1000,
peak_guessed: false,
});
let next = candidate(&settings, caps, reading, &published);
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
@@ -731,7 +729,6 @@ mod tests {
luminance: (hdr == Some(true)).then_some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
}),
hdr,
};
@@ -1,79 +0,0 @@
//! Hardware ray queries, only on request (#13780, after the #1180 spike): `HORIZON_RAY_TRACING=1`
//! at startup asks the device for wgpu's `EXPERIMENTAL_RAY_QUERY` when the GPU has it. Nothing
//! draws with it yet. Without the variable, and in a build without `horizon-extras` (a Horizon
//! extra, docs/layers.md), the device is opened exactly as before.
/// The ray query feature.
const RAY_QUERY: wgpu::Features = wgpu::Features::EXPERIMENTAL_RAY_QUERY;
/// Whether the player asked for ray tracing (the device needs the feature from its creation).
fn requested() -> bool {
asked(std::env::var("HORIZON_RAY_TRACING").ok().as_deref())
}
/// Whether `HORIZON_RAY_TRACING` set to `var` asks for ray tracing: only in a `horizon-extras`
/// build.
fn asked(var: Option<&str>) -> bool {
cfg!(feature = "horizon-extras") && var == Some("1")
}
/// The features to request on `adapter`: ray queries, when asked for and offered.
pub(super) fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
let features = pick(requested(), adapter.features());
if requested() {
let info = adapter.get_info();
if features.is_empty() {
log::warn!(
"Horizon renderer: ray tracing asked for, but {} on {:?} has no hardware ray \
queries; it stays off",
info.name,
info.backend
);
} else {
log::info!(
"Horizon renderer: ray queries on ({} on {:?})",
info.name,
info.backend
);
}
}
features
}
fn pick(requested: bool, offered: wgpu::Features) -> wgpu::Features {
if requested {
offered & RAY_QUERY
} else {
wgpu::Features::empty()
}
}
/// The device's opt-in to `features`' experimental ones.
pub(super) fn experimental(features: wgpu::Features) -> wgpu::ExperimentalFeatures {
if features.contains(RAY_QUERY) {
horizon_hdr::experimental_features()
} else {
wgpu::ExperimentalFeatures::disabled()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_an_extras_build_asks_for_ray_tracing() {
assert_eq!(asked(Some("1")), cfg!(feature = "horizon-extras"));
assert!(!asked(None));
assert!(!asked(Some("0")));
}
#[test]
fn ray_queries_only_when_asked_and_offered() {
let all = wgpu::Features::all();
assert_eq!(pick(true, all), RAY_QUERY);
assert!(pick(false, all).is_empty());
assert!(pick(true, all - RAY_QUERY).is_empty());
assert!(!experimental(wgpu::Features::empty()).is_enabled());
}
}
@@ -5,9 +5,6 @@ use super::{
Renderer, check_layers, cull, describe_adapter, is_software, oit, startup_step, surface_format,
timing,
};
// Ray tracing on request, which the device needs from its creation (gfx.rs may not grow).
#[path = "ray_query.rs"]
mod ray_query;
use std::sync::Arc;
use winit::window::Window;
@@ -137,7 +134,6 @@ pub(super) async fn open_adapter(
let features = timing::GpuTimer::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| ray_query::features(adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MULTI_DRAW_INDIRECT_COUNT
@@ -170,7 +166,6 @@ pub(super) async fn open_adapter(
// with the larger blocks a few small buffers kept whole blocks the arena's pages had
// left, and over half the video memory reserved was unused (1,344 MiB for 500).
memory_hints: wgpu::MemoryHints::MemoryUsage,
experimental_features: ray_query::experimental(features),
..Default::default()
};
#[cfg(not(target_os = "macos"))]
@@ -30,22 +30,6 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// 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,
}
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but only guesses the peak: the player calibrates.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
@@ -1223,15 +1207,6 @@ impl Graphics {
n => n,
}
}
/// How HDR is set up for the display the system `reported`.
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
match reported {
None => HdrSetup::Unknown,
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
Some(r) if r.peak_guessed => HdrSetup::Guessed,
Some(_) => HdrSetup::Automatic,
}
}
/// The black level, in nits. Not the system's: files from before saved 0 for that
/// (Auto), and a reported black would lift every display's shadows by default.
pub fn black_level_nits(&self) -> f32 {
@@ -1345,7 +1320,6 @@ mod tests {
let reported = Some(Reported {
paper_white: 300,
peak: 1000,
peak_guessed: false,
});
assert_eq!(g.ui_nits(reported), 300);
g.hdr_paper_white = 120;
@@ -1354,33 +1328,6 @@ mod tests {
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
}
#[test]
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
peak_guessed: false,
};
let windows = Reported {
paper_white: 400,
peak: 1000,
peak_guessed: true,
};
let mut g = Graphics::default();
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 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)] {
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
}
}
#[test]
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
+23 -11
View File
@@ -8,8 +8,7 @@ use crate::{
gfx::EffectiveOutput,
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
Preset, Upscaling,
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
},
graphics_style::Cost,
lang,
@@ -676,9 +675,28 @@ const LOD_STEPS: [u32; 24] = [
crate::lod::MAX_CHUNKS,
];
/// Whether the system set HDR up for the display, and whether the player kept it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Setup {
/// The system says nothing: the player calibrates.
Unknown,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
s.horizon.hdr_setup(out.reported)
setup_of(&s.horizon, out.reported.is_some())
}
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
if !reported {
Setup::Unknown
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
Setup::Automatic
} else {
Setup::Changed
}
}
/// What the HDR page says while HDR output is held back on this window's graphics driver
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
@@ -696,19 +714,13 @@ fn description(
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"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."
}
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
// 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) =>
{
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
@@ -932,7 +944,7 @@ impl H {
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", g.hdr.label()),
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
Setup::Unknown | Setup::Guessed => "Calibrate...",
Setup::Unknown => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
Setup::Changed => "Use Automatic Setup",
})),
@@ -120,86 +120,14 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
}
/// The setup rows' label and description for each kind of report: none, a compositor'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));
let mut guessed = trusted;
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let rows = |s: &Settings, out: &EffectiveOutput| {
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
let item = Item::Horizon(h);
(item.label(s, out).plain(), description(item, s, out, "-"))
})
};
let calibrate = "Calibrate...";
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let none = (
calibrate.into(),
"Your display didn't say how bright it gets: calibrate to set it up.",
);
assert_eq!(
rows(&s, &NONE),
[
none.clone(),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (203)".into(), "-"),
]
);
assert_eq!(
rows(&s, &guessed),
[
(
calibrate.into(),
"Your display's reported peak is often too high: calibrate to set it up."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
]
);
assert_eq!(
rows(&s, &trusted),
[
(
"Calibrate Anyway...".into(),
"Already set up for your display. Only calibrate if HDR looks wrong."
),
("Peak Brightness: Auto (1000)".into(), set_up),
("Paper White: Auto (400)".into(), set_up),
]
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
"Use Automatic Setup".into(),
"Puts Peak Brightness and Paper White back to your display's setup.",
),
("Peak Brightness: 800".into(), peak),
("Paper White: Auto (400)".into(), set_up),
]
};
assert_eq!(rows(&s, &guessed), changed("-"));
assert_eq!(rows(&s, &trusted), changed(set_up));
assert_eq!(rows(&s, &NONE)[0], none);
let mut ui = ui();
ui.screen.output = guessed;
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
assert_eq!(
press(&mut ui, &mut s, at(calibrate)),
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
);
fn hdr_setup_follows_what_the_system_reported() {
let mut g = crate::graphics::Graphics::default();
assert_eq!(setup_of(&g, false), Setup::Unknown);
assert_eq!(setup_of(&g, true), Setup::Automatic);
g.hdr_paper_white = 80;
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
}
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
+1
View File
@@ -67,6 +67,7 @@ mod jei_sprites;
pub mod jigsaw_block;
pub mod keypad;
pub mod terrain_model;
pub use horizon_polytone::colors as polytone_colors;
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
pub use horizon_world::json_folder;
pub mod level_loading;
+2 -2
View File
@@ -2593,7 +2593,8 @@ impl Lod {
self.returned_at = std::time::Instant::now();
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
self.plan.nodes.contains(key)
});
@@ -2679,7 +2680,6 @@ impl Lod {
}
if let Some((columns, mask)) = self.kept.take(&key) {
self.ready.insert(key, columns);
self.sync_sample(key);
if let Some(mask) = mask {
self.detail_masks.insert(key, mask);
}
@@ -1,7 +1,6 @@
//! Optional, texture-matched overlays drawn full bright over their base texture in the same
//! terrain fragment: native mods' (`<ns>:emissive/...`) and a pack's emissive block textures
//! (OptiFine's `block/stone_e.png` beside `block/stone.png`, the suffix from the pack formats,
//! [`crate::pack_hooks`]). No extra draw calls or world light changes.
//! terrain fragment: native mods' (`<ns>:emissive/...`) and OptiFine's emissive block textures
//! (`block/stone_e.png` beside `block/stone.png`). No extra draw calls or world light changes.
use crate::sprites::Sprites;
use std::{collections::BTreeMap, path::Path};
@@ -36,12 +35,14 @@ fn tile_file(name: &str) -> Option<&str> {
name.strip_suffix(".png")
}
/// A pack's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each `<ns>:block/<name>`, or connected-texture tile
/// `assets/<ns>/.../<name>.png`, whose `<name><suffix>.png` sits beside it, the overlay is loaded
/// too and paired with it, blocks first, then tiles, each by name, at most [`MAX`] - [`NATIVE`]
/// (native mods keep their share of the table). A pack without such files adds nothing.
pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(String, String)> {
/// OptiFine's emissive overlays of the block sprites already loaded (what the terrain draws;
/// items draw from the HUD atlas): for each
/// `<ns>:block/<name>`, or connected-texture tile `assets/<ns>/.../<name>.png`, whose
/// `<name><suffix>.png` sits beside it, the overlay is loaded too and paired with it, blocks
/// first, then tiles, each by name, at most [`MAX`] - [`NATIVE`] (native mods keep their share of
/// the table). A pack without such files adds nothing.
pub fn load_optifine(root: &Path, sprites: &mut Sprites) -> Vec<(String, String)> {
let suffix = horizon_optifine::emissive::suffix(root);
// Blocks with identical pixels share a layer (and the shader pairs by layer): a shared
// base would glow on every block that shares it, so it gets no overlay.
let mut sharing: BTreeMap<u32, usize> = BTreeMap::new();
@@ -55,8 +56,8 @@ pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(S
sharing[layer] == 1
&& (name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(suffix)))
.is_some_and(|(_, p)| p.starts_with("block/") && !p.ends_with(&suffix))
|| tile_file(name).is_some_and(|stem| !stem.ends_with(&suffix)))
})
.map(|(name, _)| name.clone())
.collect();
@@ -109,7 +110,7 @@ pub fn load_overlays(root: &Path, suffix: &str, sprites: &mut Sprites) -> Vec<(S
pub const CUTOUT: u32 = 1 << 31;
/// The frame's overlay table: native mods' pairs (at most [`NATIVE`] requested), then the packs'
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_overlays`] loads),
/// OptiFine pairs flagged [`CUTOUT`] (at most [`MAX`] - [`NATIVE`], as [`load_optifine`] loads),
/// one per base layer with native mods' first, sorted by base layer for the shader's early exit.
pub fn table<'a>(
layers: &BTreeMap<String, u32>,
@@ -203,7 +204,7 @@ mod tests {
// Items draw from the HUD atlas, not the terrain: no pair.
sprites.insert(&root, "minecraft:item/stick").unwrap();
// glowstone isn't loaded: its overlay pairs with nothing.
let pairs = load_overlays(&root, "_e", &mut sprites);
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
[(
@@ -214,6 +215,9 @@ mod tests {
assert!(sprites.layers.contains_key("minecraft:block/stone_e"));
assert!(!sprites.layers.contains_key("minecraft:block/granite_e"));
// The pack's own suffix.
let props = root.join("assets/minecraft/optifine/emissive.properties");
std::fs::create_dir_all(props.parent().unwrap()).unwrap();
std::fs::write(&props, "suffix.emissive=_glow").unwrap();
// Connected-texture tiles pair with the overlay beside them.
let tiles = root.join("assets/minecraft/optifine/ctm/lamp");
png(&tiles.join("0.png"), 230);
@@ -230,7 +234,7 @@ mod tests {
.insert_file(&root, &format!("assets/minecraft/optifine/{tile}.png"))
.unwrap();
}
let pairs = load_overlays(&root, "_glow", &mut sprites);
let pairs = load_optifine(&root, &mut sprites);
assert_eq!(
pairs,
// Blocks first, then tiles.
@@ -1,67 +0,0 @@
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
use std::path::Path;
use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
let env = colors.env;
pack_palette::PackColors {
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
dye_text: by_dye(&|name| colors.dye(name, "text")),
sheep: by_dye(&|name| colors.sheep(name)),
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
effects: colors.effects(),
entity_shadows: colors.entity_shadows(),
xp_bar: colors.xp_bar(),
env: Environment {
powder_snow_fog: env.powder_snow_fog,
blindness_fog_distance: env.blindness_fog_distance,
darkness_fog_distance: env.darkness_fog_distance,
boss_fog_distance: env.boss_fog_distance,
water_fog_brightening: env.water_fog_brightening,
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
#[test]
fn a_pack_recolours_only_what_it_names() {
// Dyes no other test draws: the table is process-wide.
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
let polytone = dir.join("assets/obs/polytone");
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
crate::pack_palette::set(&read(&dir));
let (cyan, pink, purple) = (9, 6, 10);
assert_eq!(diffuse(cyan), 0x00ff00);
assert_eq!(text(cyan), 0x0000ff);
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
crate::pack_palette::set(&Default::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -1,146 +0,0 @@
//! Polytone's item modifiers' durability bar colours, on the engine's
//! [`crate::pack_palette::ItemBars`].
use std::path::Path;
use std::sync::Arc;
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
struct DamageBar {
targets: horizon_polytone::Targets,
row: Vec<u32>,
}
/// The packs' damage bars, the last read first.
struct Bars(Vec<DamageBar>);
impl crate::pack_palette::ItemBars for Bars {
fn color(&self, item: &str, damage: f32) -> Option<u32> {
let bar = self
.0
.iter()
.rev()
.find(|b| b.targets.matches(item, |_| false))?;
let last = (bar.row.len() - 1) as f32;
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
}
}
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read. `None` without any.
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
let mut bars = Vec::new();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let base = namespace.path().join("polytone/item_modifiers");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(bar) = item_bar(root, &base, &path) {
bars.push(bar);
}
}
}
}
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
}
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
let name = path.file_name()?.to_str()?;
let row = |png: &Path| {
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
let row: Vec<u32> = rgba[..width as usize * 4]
.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect();
(!row.is_empty()).then_some(row)
};
if let Some(stem) = name.strip_suffix("_bar.png") {
// A json of the same name owns its png.
if path.with_file_name(format!("{stem}.json")).exists() {
return None;
}
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
return Some(DamageBar {
targets,
row: row(path)?,
});
}
let stem = name.strip_suffix(".json")?;
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
if json.get("bar_color")?.as_str()? != "damage" {
return None;
}
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
.into_iter()
.map(|png| path.with_file_name(png))
.find(|png| png.exists())?;
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
let targets =
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
Some(DamageBar {
targets,
row: row(&png)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
let data: Vec<u8> = pixels
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
#[test]
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
std::fs::write(
modifiers.join("tools.json"),
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
)
.unwrap();
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
let bars = read(&dir).unwrap();
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
assert!(read(&dir.join("none")).is_none());
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -3,9 +3,7 @@
//! 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.
mod colors;
mod custom_sky;
mod item_bars;
use std::path::Path;
use std::sync::Arc;
@@ -16,22 +14,10 @@ use crate::pack_hooks::PackFormats;
pub struct Formats;
impl PackFormats for Formats {
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
Some(colors::read(root))
}
fn emissive_suffix(&self, root: &Path) -> Option<String> {
Some(horizon_optifine::emissive::suffix(root))
}
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 item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
item_bars::read(root)
}
}
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
@@ -10,26 +10,10 @@ use std::sync::{Arc, OnceLock};
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
/// (its folder at `root`); the default is "nothing in this format".
pub trait PackFormats: Send + Sync {
/// The colours the overlay replaces where vanilla hardcodes them.
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
None
}
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
/// if its format has them.
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
None
}
/// The 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 durability bar colours the overlay sets by item, if any.
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
None
}
}
/// No format beyond vanilla's.
+184 -113
View File
@@ -1,87 +1,14 @@
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
//! and the mesher's workers read the dye, sheep and redstone tables per face.
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
use std::path::Path;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::{Arc, RwLock};
use crate::block_entity_render::DYES;
use crate::signs::DYE_TEXT;
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Environment {
/// The fog colour inside powder snow (RGB).
pub powder_snow_fog: Option<u32>,
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
pub blindness_fog_distance: f32,
pub darkness_fog_distance: f32,
pub boss_fog_distance: f32,
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
pub water_fog_brightening: f32,
/// The ticks under water it takes (vanilla's 600).
pub water_fog_brightening_time: f32,
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
pub horizon_height: f32,
}
impl Default for Environment {
fn default() -> Self {
Self {
powder_snow_fog: None,
blindness_fog_distance: 5.0,
darkness_fog_distance: 15.0,
boss_fog_distance: 96.0,
water_fog_brightening: 1.0,
water_fog_brightening_time: 600.0,
horizon_height: 63.0,
}
}
}
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
#[derive(Clone, Debug, PartialEq)]
pub struct PackColors {
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
pub dye_diffuse: [Option<u32>; 16],
pub dye_text: [Option<u32>; 16],
pub sheep: [Option<u32>; 16],
/// `RedStoneWireBlock.COLORS` by power.
pub redstone: [Option<u32>; 16],
/// Mob effects' colours, by effect id without vanilla's namespace.
pub effects: Vec<(String, u32)>,
/// False hides every entity's round shadow, whatever the video option says.
pub entity_shadows: bool,
/// The experience level number's colour and outline.
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
}
impl Default for PackColors {
fn default() -> Self {
Self {
dye_diffuse: [None; 16],
dye_text: [None; 16],
sheep: [None; 16],
redstone: [None; 16],
effects: Vec::new(),
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
}
}
}
impl PackColors {
/// Whether entities draw their round shadow, as far as the packs say.
pub fn entity_shadows(&self) -> bool {
self.entity_shadows
}
}
use horizon_polytone::colors::PackColors;
/// 0 is unset, else `1 << 32 | rgb`.
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
@@ -89,30 +16,6 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
/// and redstone colours go to the draw paths' tables ([`set`]).
pub fn read(root: &Path) -> PackColors {
let formats = crate::pack_hooks::formats();
let colors = formats.colors(root).unwrap_or_default();
set(&colors);
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
colors
}
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
pub trait ItemBars: Send + Sync {
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
fn color(&self, item: &str, damage: f32) -> Option<u32>;
}
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
bars.as_ref()?.color(item, damage)
}
/// Takes the colours of the packs just loaded (vanilla's where they set none).
pub fn set(colors: &PackColors) {
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
@@ -121,39 +24,129 @@ pub fn set(colors: &PackColors) {
Relaxed,
);
};
for i in 0..16 {
store(&DIFFUSE, i, colors.dye_diffuse[i]);
store(&TEXT, i, colors.dye_text[i]);
store(&SHEEP, i, colors.sheep[i]);
store(&REDSTONE, i, colors.redstone[i]);
for (i, (name, _)) in DYES.iter().enumerate() {
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
store(&TEXT, i, colors.dye(name, "text"));
store(&SHEEP, i, colors.sheep(name));
store(&REDSTONE, i, colors.redstone(i as u8));
}
}
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
struct DamageBar {
targets: horizon_polytone::Targets,
row: Vec<u32>,
}
static ITEM_BARS: RwLock<Vec<DamageBar>> = RwLock::new(Vec::new());
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read.
pub fn set_item_bars(root: &Path) {
let mut bars = Vec::new();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let base = namespace.path().join("polytone/item_modifiers");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(bar) = item_bar(root, &base, &path) {
bars.push(bar);
}
}
}
}
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = bars;
}
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
let name = path.file_name()?.to_str()?;
let row = |png: &Path| {
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
let row: Vec<u32> = rgba[..width as usize * 4]
.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect();
(!row.is_empty()).then_some(row)
};
if let Some(stem) = name.strip_suffix("_bar.png") {
// A json of the same name owns its png.
if path.with_file_name(format!("{stem}.json")).exists() {
return None;
}
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
return Some(DamageBar {
targets,
row: row(path)?,
});
}
let stem = name.strip_suffix(".json")?;
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
if json.get("bar_color")?.as_str()? != "damage" {
return None;
}
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
.into_iter()
.map(|png| path.with_file_name(png))
.find(|png| png.exists())?;
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
let targets =
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
Some(DamageBar {
targets,
row: row(&png)?,
})
}
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
let bar = bars
.iter()
.rev()
.find(|b| b.targets.matches(item, |_| false))?;
let last = (bar.row.len() - 1) as f32;
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
}
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
let v = table[i & 15].load(Relaxed);
(v != 0).then_some(v as u32)
}
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
pub fn diffuse(dye: usize) -> u32 {
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
}
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
pub fn text(dye: usize) -> u32 {
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
}
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
pub fn redstone(power: u8) -> Option<u32> {
get(&REDSTONE, usize::from(power))
read(&REDSTONE, usize::from(power))
}
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
pub fn sheep_wool(dye: u8) -> u32 {
let i = usize::from(dye & 15);
if let Some(color) = get(&SHEEP, i) {
if let Some(color) = read(&SHEEP, i) {
return color;
}
if i == 0 {
@@ -163,3 +156,81 @@ pub fn sheep_wool(dye: u8) -> u32 {
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pack_recolours_only_what_it_names() {
// Dyes no other test draws: the table is process-wide.
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
let polytone = dir.join("assets/obs/polytone");
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
set(&PackColors::read(&dir));
let (cyan, pink, purple) = (9, 6, 10);
assert_eq!(diffuse(cyan), 0x00ff00);
assert_eq!(text(cyan), 0x0000ff);
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
set(&PackColors::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
let data: Vec<u8> = pixels
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
#[test]
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
std::fs::write(
modifiers.join("tools.json"),
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
)
.unwrap();
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
set_item_bars(&dir);
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(item_bar_color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(item_bar_color("minecraft:diamond_shovel", 0.5), None);
set_item_bars(&dir.join("none"));
assert_eq!(item_bar_color("minecraft:diamond_pickaxe", 0.5), None);
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -801,26 +801,6 @@ impl Host for Ctx<'_, '_> {
_ => Err(format!("{name} is not available here")),
}
}
/// A particle's own fields, as `particle_method` answers them.
fn field(&mut self, root: Root, field: horizon_polytone::expr::Field) -> Option<V> {
use horizon_polytone::expr::Field as F;
let Subject::Particle(p) = &self.subject else {
return None;
};
if root != Root::Object {
return None;
}
Some(V::Num(match field {
F::Age => f64::from(p.age),
F::Life => f64::from(p.lifetime),
F::Custom => *self.custom,
F::X => p.position[0],
F::Y => p.position[1],
F::Z => p.position[2],
F::Temperature | F::Downfall => return None,
}))
}
}
fn eval(program: &Program, ctx: &mut Ctx) -> Option<f64> {
@@ -337,6 +337,13 @@ impl Settings {
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
/// Loads only the crates' settings stores (`horizon_hooks::settings`) from the files next to
/// `path`, into the seat whose turn it is (`horizon_hooks::seat`): a splitscreen player whose
/// settings were made from P1's rather than loaded.
pub fn load_stores(path: &Path) {
let dir = path.parent();
horizon_hooks::settings::engine::load(&mut |file| fs::read_to_string(dir?.join(file)).ok());
}
pub fn load(path: &Path) -> Self {
let mut horizon = path
.parent()
@@ -913,20 +913,6 @@ pub trait Backend {
fn unpause(&mut self, channel: ChannelId);
/// `Channel.stopped`.
fn stopped(&mut self, channel: ChannelId) -> bool;
/// Update volume, pitch, and position in a single backend call.
fn update_channel(&mut self, channel: ChannelId, volume: f32, pitch: f32, position: [f64; 3]) {
self.set_volume(channel, volume);
self.set_pitch(channel, pitch);
self.set_position(channel, position);
}
/// Filter which handles have stopped playing in a single pass.
fn filter_stopped(&mut self, live: &[(u64, ChannelId)], stopped: &mut Vec<u64>) {
for &(h, c) in live {
if self.stopped(c) {
stopped.push(h);
}
}
}
/// Whether the channel plays clearly through a mod's muffle (`Audio::muffle`): its sound
/// event is one the mod exempted. A backend without a muffle ignores it.
fn set_unmuffled(&mut self, _channel: ChannelId, _unmuffled: bool) {}
@@ -1508,14 +1494,13 @@ impl SoundEngine {
.iter()
.filter_map(|(h, handle)| handle.channel.map(|c| (*h, c))),
);
let mut stopped = std::mem::take(&mut self.mapped_scratch);
stopped.clear();
backend.filter_stopped(&live, &mut stopped);
for h in stopped.drain(..) {
self.release_handle(h, backend);
for (h, c) in live.drain(..) {
if backend.stopped(c) {
self.release_handle(h, backend);
}
}
// Released handles nobody maps any more.
let mut mapped = stopped;
let mut mapped = std::mem::take(&mut self.mapped_scratch);
mapped.clear();
mapped.extend(self.instance_to_channel.iter().map(|(_, h)| *h));
self.handles
@@ -1565,7 +1550,9 @@ impl SoundEngine {
h.pitch_edited = pitch_edited;
}
if let Some(c) = self.handles.get(&handle).and_then(handle_channel) {
backend.update_channel(c, volume, pitch, position);
backend.set_volume(c, volume);
backend.set_pitch(c, pitch);
backend.set_position(c, position);
}
let instance = self.instances.get_mut(&id).expect("present");
if instance.looping
+1 -23
View File
@@ -1251,9 +1251,6 @@ pub struct EditBox {
/// `getMessage`: what the narrator calls the box ("<message> edit box:
/// <value>").
pub message: String,
/// `EditBox.setInvertHighlightedTextColor`: a selection draws its text
/// inverted (the default) or in the box's own colour (`AnvilScreen`).
pub invert_highlighted_text_color: bool,
/// Where a borderless box drawn in a frame of its own focuses: a controller's outline goes
/// round the frame, not over the text.
pub frame: Option<[f32; 4]>,
@@ -1546,7 +1543,6 @@ impl EditBox {
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
invert_highlighted_text_color: true,
frame: None,
}
}
@@ -1922,11 +1918,7 @@ impl EditBox {
let right = self.x + self.width;
let (x0, x1) = (cursor_x.min(right), (hx - 1.0).min(right));
let (a, b) = (x0.min(x1), x0.max(x1));
batch.text_highlight(
atlas,
(a, ty - 1.0, b - a, 11.0),
self.invert_highlighted_text_color,
);
batch.text_highlight(atlas, (a, ty - 1.0, b - a, 11.0), true);
}
if show_cursor {
if insert {
@@ -2636,20 +2628,6 @@ mod tests {
);
}
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
/// unless the box asks it not to (the anvil's rename box).
#[test]
fn a_selection_inverts_its_text_unless_the_box_says_not_to() {
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
assert!(edit.invert_highlighted_text_color);
edit.invert_highlighted_text_color = false;
edit.set_value("name");
edit.set_cursor(0, false);
edit.set_cursor(4, true);
assert_eq!(edit.selected_text(), "name");
assert!(!edit.invert_highlighted_text_color);
}
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
/// copies without taking the text out. A value and the caret still show,
+2 -3
View File
@@ -119,11 +119,10 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends, and ray
# tracing on request (`HORIZON_RAY_TRACING=1`). Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["horizon-client-lib/horizon-extras", "horizon-client-render/horizon-extras", "discord", "friends"]
horizon-extras = ["horizon-client-lib/horizon-extras", "discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
@@ -441,7 +441,6 @@
"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.",
"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.",
+5 -6
View File
@@ -21,11 +21,11 @@ pub use horizon_client_render::{
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,
polytone_colors, 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;
@@ -171,7 +171,6 @@ pub use horizon_client_input::gamepad;
pub use horizon_client_input::gameplay_guide;
pub use horizon_client_input::held;
pub use horizon_client_input::input;
pub use horizon_client_input::input_source;
pub use horizon_client_input::interaction_input;
pub use horizon_client_input::key_names;
pub use horizon_client_input::keybinds;
+3 -3
View File
@@ -2666,9 +2666,9 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
m.extra.color = number(tag.get("Color")).map_or(16, |c| c.clamp(0, 16) as u8);
m.anim.v[0] = m.extra.counter as f32 * 0.01;
m.anim.vo[0] = m.anim.v[0];
// Shulker.setPos: square on its block.
+3 -2
View File
@@ -23,8 +23,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
// Crates above the engine registered their hooks before this (horizon-official's `main`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
horizon_hooks::engine::seal();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
+106 -60
View File
@@ -373,6 +373,7 @@ mod place_feature_tests;
mod play_input;
mod play_keys;
mod player_equip;
pub(crate) mod player_hooks;
mod player_spawn;
mod pointer_capture;
#[cfg(test)]
@@ -2028,7 +2029,7 @@ pub struct PackResources {
/// The packs' Polytone post chains (#11486), when a pack has some.
polytone_post: Option<Arc<horizon_client::polytone_post::PolytonePost>>,
/// The packs' Polytone custom colours (`polytone/colors.json`).
polytone_colors: Arc<horizon_client::pack_palette::PackColors>,
polytone_colors: Arc<horizon_client::polytone_colors::PackColors>,
}
/// A quick craft drag as the screen draws it: each dragged slot's count and
/// whether the slot caps it (None: hidden), and what stays in hand.
@@ -2882,8 +2883,19 @@ struct Game<'a> {
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
/// service's presence status.
realm_session: bool,
/// Horizon Studio: replay playback, its free camera, timeline and top bar (game/studio.rs).
pub(super) studio: studio::Studio,
/// Active packet replay session (game/studio.rs).
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
pub(super) studio_rmb_down: bool,
studio_camera_track_dirty: bool,
/// Camera perspective to restore after leaving replay playback.
studio_replay_previous_camera_type: Option<perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
studio_replay_standalone: bool,
last_replay_tick: Option<Instant>,
pub(super) studio_virtual_tick_ms: f32,
/// This world is open to the network (game/lan.rs).
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
@@ -5015,7 +5027,7 @@ impl Game<'_> {
if canceled {
return Ok(false);
}
self.input_feedback(horizon_client::input_source::Feedback::BlockBroken);
self.pad_rumble(horizon_client::gamepad::Rumble::BlockBroken);
let broken = state;
let facts = self.res.facts().get(state).clone();
let state = self.crack_mined_pot(pos, state);
@@ -5834,7 +5846,7 @@ impl Game<'_> {
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
self.input_hud_begin();
self.pad.guide_shown.clear();
let mut batch = HudBatch::new(size);
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
@@ -5931,7 +5943,7 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let in_studio = self.studio.replay.is_some();
let in_studio = self.studio_replay.is_some();
let show_hud = !self.ui.hide_gui && !in_studio;
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
// defers the subtitles over itself, and keeps them while F1 hides the
@@ -6131,7 +6143,7 @@ impl Game<'_> {
let atlas = &g.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
let draws = hud_layout::experience_colored(
g.pack.polytone_colors.xp_bar,
g.pack.polytone_colors.xp_bar(),
wi,
hi,
g.experience.progress,
@@ -6273,19 +6285,19 @@ impl Game<'_> {
}
if !self.ui.hide_gui
&& self.screen == Screen::Playing
&& (self.studio.replay.is_some() || self.studio.freecam.active)
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
self.studio.topbar.render(
self.studio_topbar.render(
&mut batch,
&self.gui.atlas,
self.cursor,
self.studio.freecam.locked_to_path,
self.studio_freecam.locked_to_path,
);
self.studio.timeline.render(
self.studio_timeline.render(
&mut batch,
&self.gui.atlas,
self.cursor,
&self.studio.freecam,
&self.studio_freecam,
);
}
if show_hud {
@@ -6464,6 +6476,18 @@ impl Game<'_> {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
// `Gui.setScreen` clears the old screen's focus before it goes: the
// key that closed the container (Tab bound to the inventory) has
@@ -6507,15 +6531,29 @@ impl Game<'_> {
}
self.close_ender_chest();
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
if self.studio.freecam.active {
if self.studio_freecam.active {
return camera_rotation(
perspective::CameraType::FirstPerson,
self.studio.freecam.yaw,
self.studio.freecam.pitch,
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
self.studio_freecam.yaw,
self.studio_freecam.pitch,
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
}
match self.posed_rotation {
// The frame's camera, already turned round in front view.
@@ -6722,8 +6760,8 @@ impl Game<'_> {
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
let player_alpha = if self.studio.replay.is_some() {
(self.studio.virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
let player_alpha = if self.studio_replay.is_some() {
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
} else {
self.clock.camera_fraction()
};
@@ -6735,8 +6773,8 @@ impl Game<'_> {
.extend(out.drain(player_start..));
}
self.mark_motion(out.len(), 0);
// Companions in first person, trails and auras (game/cosmetics/fx.rs).
self.cosmetics_fx_geometry(camera, alpha, &mut out);
// What crates draw around players: first person, see-through (game/player_hooks.rs).
self.players_after_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
self.fangs_geometry(camera, alpha, &mut out);
self.block_item_display_geometry(camera, alpha, &mut out);
@@ -7455,7 +7493,6 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
@@ -9225,7 +9262,7 @@ impl Game<'_> {
let camera_alpha = self.clock.camera_fraction();
let vanilla = self.camera_view(camera_alpha as f32);
let view = vanilla.map(|view| {
if self.shows_panorama() || self.studio.freecam.active {
if self.shows_panorama() || self.studio_freecam.active {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
@@ -9403,9 +9440,9 @@ impl Game<'_> {
// hitboxes (F3+G, F3+B).
let mut lines = self.fishing_line(alpha, size);
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
if self.studio.freecam.active || self.studio.replay.is_some() {
if self.studio_freecam.active || self.studio_replay.is_some() {
horizon_client::studio::render_camera_path(
&self.studio.freecam.track,
&self.studio_freecam.track,
&mut g,
None,
100,
@@ -9432,7 +9469,7 @@ impl Game<'_> {
Vec::new()
};
pr.lap("pr_native_visuals");
let view = if self.studio.freecam.active {
let view = if self.studio_freecam.active {
glam::Mat4::from_quat(self.camera_orientation().conjugate())
} else {
horizon_client::camera::view_matrix(view_yaw, view_pitch)
@@ -9558,7 +9595,7 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
let camera_effects = fov::camera_effects_enabled(title, self.studio.freecam.active);
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
let bob = if !camera_effects {
glam::Mat4::IDENTITY
} else {
@@ -10063,7 +10100,7 @@ impl Game<'_> {
)
)
{
self.input_keyboard_used();
self.pad.keyboard_took_over = true;
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Pressed
@@ -10452,19 +10489,19 @@ impl Game<'_> {
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.screen == Screen::Playing
&& (self.studio.timeline.dragging_playhead
|| self.studio.timeline.dragging_keyframe.is_some())
&& (self.studio_timeline.dragging_playhead
|| self.studio_timeline.dragging_keyframe.is_some())
{
let action = self.studio.timeline.handle_mouse_drag(
let action = self.studio_timeline.handle_mouse_drag(
self.cursor,
b.width,
&mut self.studio.freecam,
&mut self.studio_freecam,
);
if matches!(
action,
horizon_client::studio::TimelineAction::KeyframeMoved
) {
self.studio.camera_track_dirty = true;
self.studio_camera_track_dirty = true;
}
return Ok(());
}
@@ -10516,7 +10553,7 @@ impl Game<'_> {
}
}
WindowEvent::MouseWheel { .. }
if self.screen == Screen::Playing && self.studio.replay.is_some() => {}
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
WindowEvent::MouseWheel { delta, .. } => {
let (raw_x, raw_y) = match delta {
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
@@ -10794,44 +10831,44 @@ impl Game<'_> {
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
if !pressed && button == MouseButton::Left {
let action = self.studio.timeline.handle_mouse_up();
let action = self.studio_timeline.handle_mouse_up();
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
}
}
if (self.studio.freecam.active || self.studio.replay.is_some())
if (self.studio_freecam.active || self.studio_replay.is_some())
&& button == MouseButton::Right
{
if let Some(renderer) = &self.renderer {
let batch =
HudBatch::new([renderer.config.width, renderer.config.height]);
if self.studio.timeline.visible
&& self.cursor.1 >= batch.height - self.studio.timeline.height()
if self.studio_timeline.visible
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
{
if pressed
&& let Some(index) = self.studio.timeline.keyframe_at_cursor(
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
self.cursor,
batch.width,
batch.height,
&self.studio.freecam,
&self.studio_freecam,
)
{
if index < self.studio.freecam.track.keyframes.len() {
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
if index < self.studio_freecam.track.keyframes.len() {
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
}
self.studio.timeline.selected_keyframe = None;
self.studio_timeline.selected_keyframe = None;
}
return Ok(());
}
}
if pressed {
self.studio.topbar.close_dropdown();
self.studio.rmb_down = true;
self.studio_topbar.close_dropdown();
self.studio_rmb_down = true;
self.capture(true);
return Ok(());
} else if self.studio.rmb_down {
self.studio.rmb_down = false;
} else if self.studio_rmb_down {
self.studio_rmb_down = false;
self.capture(false);
return Ok(());
}
@@ -10839,7 +10876,7 @@ impl Game<'_> {
if pressed
&& button == MouseButton::Left
&& self.screen == Screen::Playing
&& (self.studio.replay.is_some() || self.studio.freecam.active)
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
let size = self
.renderer
@@ -10847,8 +10884,8 @@ impl Game<'_> {
.map(|renderer| [renderer.config.width, renderer.config.height])
.unwrap_or([1, 1]);
let batch = HudBatch::new(size);
let had_open_menu = self.studio.topbar.open_menu.is_some();
let topbar_action = self.studio.topbar.handle_mouse_click(
let had_open_menu = self.studio_topbar.open_menu.is_some();
let topbar_action = self.studio_topbar.handle_mouse_click(
self.cursor,
batch.width,
batch.height,
@@ -10857,26 +10894,26 @@ impl Game<'_> {
self.handle_studio_topbar_action(topbar_action);
return Ok(());
}
if had_open_menu || self.cursor.1 <= self.studio.topbar.height {
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
return Ok(());
}
if self.studio.timeline.visible {
let action = self.studio.timeline.handle_mouse_down(
if self.studio_timeline.visible {
let action = self.studio_timeline.handle_mouse_down(
self.cursor,
batch.width,
batch.height,
&self.studio.freecam,
&self.studio_freecam,
);
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
return Ok(());
}
if self.cursor.1 >= batch.height - self.studio.timeline.height() {
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
return Ok(());
}
}
}
if self.screen == Screen::Playing && self.studio.replay.is_some() {
if self.screen == Screen::Playing && self.studio_replay.is_some() {
return Ok(());
}
if matches!(self.screen, Screen::Inventory | Screen::Creative)
@@ -11346,7 +11383,7 @@ impl Game<'_> {
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
if self.studio.freecam.active
if self.studio_freecam.active
&& self.screen == Screen::Playing
&& !self.dialog_open()
{
@@ -11356,7 +11393,7 @@ impl Game<'_> {
let vertical =
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio.freecam.update_flight_motion(
self.studio_freecam.update_flight_motion(
forward,
strafe,
vertical,
@@ -11436,7 +11473,7 @@ impl Game<'_> {
self.extras_frame(elapsed);
self.extras_retry()?;
self.apply_mouse_look();
self.input_look(elapsed.as_secs_f32());
self.pad_look(elapsed.as_secs_f32());
self.advance_flight();
self.tick_world_icon();
let ticked = Instant::now();
@@ -12146,7 +12183,16 @@ impl<'a> Game<'a> {
entity_attachments: Default::default(),
remote: None,
realm_session: false,
studio: Default::default(),
studio_replay: None,
studio_freecam: Default::default(),
studio_timeline: Default::default(),
studio_topbar: Default::default(),
studio_rmb_down: false,
studio_camera_track_dirty: false,
studio_replay_previous_camera_type: None,
studio_replay_standalone: false,
last_replay_tick: None,
studio_virtual_tick_ms: 0.0,
lan: None,
integrated: None,
level_shapes: Default::default(),
+2 -2
View File
@@ -644,7 +644,7 @@ impl Game<'_> {
.into());
}
if arg(2)? != "up" {
self.input_keyboard_used();
self.pad.keyboard_took_over = true;
}
// The command confirmation takes the keys; none go past it.
if self.command_prompt.is_some() {
@@ -800,7 +800,7 @@ impl Game<'_> {
// controller still holding one keeps it.
let options = [self.settings.toggle_crouch, self.settings.toggle_sprint];
for (slot, name) in [(0, "key.sneak"), (1, "key.sprint")] {
if !self.input_holds(name) {
if !self.pad.holds_action(&self.settings.controller, name) {
self.toggles.key(
slot,
horizon_client::keymapping::KeyAction::Release,
File diff suppressed because it is too large. Load diff
@@ -10,6 +10,7 @@ use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_cosmetics::account::OwnedEmote;
use horizon_cosmetics::{Item, Playing, Slot};
use horizon_hooks::players::PlayerModels;
use std::sync::Arc;
/// How far the player may drift (blocks) before the emote stops: a nudge, not a step.
@@ -37,10 +38,15 @@ pub(in crate::game) struct Emotes {
impl Emotes {
/// The emote to tell the service about, while it plays: its id and whether it loops.
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
pub(in crate::game) fn sending(&self) -> Option<(&str, bool)> {
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
Some((p.item.id.clone(), looping))
Some((&p.item.id, looping))
}
/// When the emote that pulled the camera back started (the own model's age), while it plays.
pub(in crate::game) fn pull(&self) -> Option<f32> {
self.camera_from.and(self.playing.as_ref()).map(|p| p.start)
}
}
@@ -59,23 +65,43 @@ impl BodyPose for EmotePose {
}
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
/// The emote a model's pose plays, if it is one.
pub(in crate::game) fn pose_playing(pose: Option<&Arc<dyn BodyPose>>) -> Option<&Playing> {
let any: &dyn std::any::Any = &**pose?;
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
let same = match (playing(m), &next) {
/// The pose that plays `next` where `now` is laid, unless it plays that already (None: keep
/// it; a new one allocates).
fn replaced(
now: Option<&Arc<dyn BodyPose>>,
next: Option<Playing>,
) -> Option<Option<Arc<dyn BodyPose>>> {
let same = match (pose_playing(now), &next) {
(None, None) => true,
(Some(a), Some(b)) => {
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
}
_ => false,
};
if !same {
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
(!same).then(|| next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>))
}
/// Makes `m` play `next`, unless it plays that already.
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
if let Some(pose) = replaced(m.emote.as_ref(), next) {
m.emote = pose;
}
}
/// Makes model `id` play `next`, unless it plays that already.
pub(in crate::game) fn set_model_playing(
models: &mut dyn PlayerModels,
id: u32,
next: Option<Playing>,
) {
if let Some(pose) = replaced(models.body_pose(id), next) {
models.set_body_pose(id, pose);
}
}
@@ -229,7 +255,7 @@ impl Game<'_> {
}
}
/// Once a tick: my emote, then the other players'.
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
pub(in crate::game) fn tick_emotes(&mut self) {
if let Some(done) = self
.cosmetics
@@ -250,20 +276,6 @@ impl Game<'_> {
}
}
self.tick_emote();
self.tick_others_emotes();
}
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
/// out over the emote's first quarter second.
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
return 1.0;
};
if self.cosmetics.emotes.camera_from.is_none() {
return 1.0;
}
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
}
}
+160 -255
View File
@@ -1,47 +1,78 @@
//! Companions, trails and auras ([docs/cosmetics.md](../../../../../docs/cosmetics.md)): worn
//! like the other cosmetics but on no bone. A companion floats beside its wearer and follows with
//! a lag (`horizon_cosmetics::follow`), drawn with the wearer; a trail's particles
//! (`horizon_cosmetics::trail`) and an aura blend, so they go after every player in one
//! see-through span (`cosmetics_fx_geometry`). Nothing of them is an entity, collides or is sent
//! to a server.
//! (`horizon_cosmetics::trail`) and an aura blend, so they are drawn see-through after every
//! player (`PlayerDraw::particles`, `Look::see_through`). Nothing of them is an entity, collides
//! or is sent to a server.
use super::{FULL_LIGHT, OWN};
use crate::game::Game;
use super::Seat;
use glam::{DVec3, Mat4, Vec3};
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::Poses;
use horizon_client::mobs::{Mob, Part};
use horizon_client::fast_hash::FxHashMap;
use horizon_cosmetics::follow::{self, Follow};
use horizon_cosmetics::trail::Emitter;
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{Kind, Light, Look, Particle, Pass, PlayerDraw, PlayerModel};
use std::sync::Arc;
use std::time::Instant;
/// How far from the camera (blocks) a wearer's companion, trail and aura are drawn.
const FAR: f32 = 48.0;
/// Who wears it, as drawn this frame.
pub(super) struct Wearer<'a> {
pub m: &'a Mob,
/// The player's own model.
pub own: bool,
/// `OWN`, or the entity id.
pub key: u32,
/// The wearer's model matrix (`render::base_transform`), relative to `camera`.
pub base: Mat4,
pub camera: DVec3,
pub alpha: f32,
pub light: u32,
/// The key of the player's own model in `Fx`'s maps (others go by entity id).
const OWN: u32 = u32::MAX;
/// What companions and trails keep between frames, per wearer (marked with the frame they were
/// last drawn in; the others are dropped).
#[derive(Default)]
pub(super) struct Fx {
companions: FxHashMap<u32, (Follow, u32)>,
trails: FxHashMap<u32, (Emitter, u32)>,
budget: Budget,
clock: Option<(Instant, Instant)>,
/// Ticks since the first frame, and the last frame's length in seconds.
now: f64,
dt: f32,
frame: u32,
/// Kept so a frame of trails allocates none.
particles: Vec<Particle>,
}
/// `m`'s head yaw this frame (degrees).
fn head_yaw(m: &Mob, alpha: f32) -> f32 {
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
m.previous_head_yaw + turn * alpha
}
impl Fx {
/// A new frame: the clock moves on and what no frame drew since the last one goes.
pub(super) fn frame(&mut self) {
self.frame = self.frame.wrapping_add(1);
let frame = self.frame;
let now = Instant::now();
let (start, last) = self.clock.get_or_insert((now, now));
self.dt = now.duration_since(*last).as_secs_f32().min(0.25);
self.now = now.duration_since(*start).as_secs_f64() * 20.0;
*last = now;
let seen = |mark: u32| frame.wrapping_sub(mark) <= 1;
self.companions.retain(|_, (_, mark)| seen(*mark));
let budget = &mut self.budget;
self.trails.retain(|_, (e, mark)| {
let keep = seen(*mark);
if !keep {
e.clear(budget);
}
keep
});
}
/// Whether `m` lies in a bed.
fn asleep(m: &Mob) -> bool {
m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn follow(&mut self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let (frame, dt) = (self.frame, self.dt);
let (f, mark) = self
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
}
/// Where a companion floats: `at` (relative to the camera) facing `yaw`, at the wearer's scale,
@@ -52,270 +83,144 @@ fn companion_matrix(at: Vec3, yaw: f32, scale: f32) -> Mat4 {
* Mat4::from_scale(Vec3::new(-scale, -scale, scale))
}
impl Game<'_> {
/// A companion, trail or aura `at` wears: a companion goes where the wearer's other items go
/// (`gpu` and `out`, `bounds` grown), trails and auras to the frame's see-through vertices.
/// Other players' trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn cosmetics_fx(
&self,
at: &Wearer,
item: &Arc<Item>,
gpu: bool,
bounds: Option<(Vec3, Vec3)>,
poses: &mut Poses,
out: &mut Vec<Vertex>,
) -> Option<(Vec3, Vec3)> {
if !at.own && item.slot.is_effect() && self.settings.extras.cosmetics_hide_fx {
return bounds;
/// Full light for what glows, else `otherwise`.
fn glowing(glow: bool, otherwise: Light) -> Light {
if glow { Light::Full } else { otherwise }
}
impl Seat {
/// A companion, trail or aura `m` wears, around its model as `draw` draws it. Other players'
/// trails and auras stay hidden with "Hide other players' trails and auras".
pub(super) fn fx(&mut self, m: &PlayerModel, item: &Arc<Item>, draw: &mut dyn PlayerDraw) {
let own = m.kind == Kind::Own;
if !own && item.slot.is_effect() && self.settings.hide_fx {
return;
}
// Past `FAR` a companion, trail or aura is a few pixels across for the cost of a model:
// not drawn, and a trail that isn't drawn doesn't spawn (its particles go back to the
// budget).
if at.base.w_axis.truncate().length_squared() > FAR * FAR {
return bounds;
let base = Mat4::from_cols_array_2d(&draw.base());
if base.w_axis.truncate().length_squared() > FAR * FAR {
return;
}
let Some(uv) = self.uv(item, |id| draw.sprite(id)) else {
return;
};
let key = if own { OWN } else { m.id };
match item.slot {
Slot::Companion => {
let Some((model, uv, matrix)) = self.companion_at(at, item, None) else {
return bounds;
};
self.pose_companion(item, at.m, at.alpha, poses, &model);
let glow = item.companion.as_ref().is_some_and(|c| c.glow);
let light = if glow { FULL_LIGHT } else { at.light };
self.cosmetics_model(&model, matrix, poses, uv, light, gpu, bounds, out)
}
Slot::Trail => {
self.trail(at, item);
bounds
}
Slot::Aura => {
self.aura(at, item, poses);
bounds
}
_ => bounds,
Slot::Companion => self.companion(key, item, uv, base, draw),
Slot::Trail => self.trail(key, item, uv, draw),
Slot::Aura => self.aura(item, uv, base, draw),
_ => {}
}
}
/// The companion's model, texture and matrix this frame, its follow moved on once a frame.
/// `first_person`: the camera's forward and left, for the player's own in first person: the
/// companion keeps out of the middle of the view.
fn companion_at(
&self,
at: &Wearer,
/// The companion where it floats, its follow moved on once a frame. In first person it keeps
/// out of the middle of the view, lit where it floats.
fn companion(
&mut self,
key: u32,
item: &Arc<Item>,
first_person: Option<(Vec3, Vec3)>,
) -> Option<(Arc<Part>, [f32; 4], Mat4)> {
let c = item.companion.as_ref()?;
let (models, uv) = self.cosmetics_baked(item)?;
let model = models.first()?.1.clone();
let scale = at.base.x_axis.truncate().length();
let target = at.base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = at
.base
.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let yaw = head_yaw(at.m, at.alpha);
let follow = self.companion_follow(at.key, at.camera + target.as_dvec3(), yaw, c.lag);
uv: [f32; 4],
base: Mat4,
draw: &mut dyn PlayerDraw,
) {
let Some(c) = item.companion.as_ref() else {
return;
};
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
let first_person = draw.pass() == Pass::FirstPerson;
let (motion, view) = (draw.motion(), draw.view());
let camera = DVec3::from(view.camera);
let scale = base.x_axis.truncate().length();
let target = base.transform_point3(Vec3::from(c.anchor) / 16.0);
let head = base.transform_point3(Vec3::from(horizon_cosmetics::HEAD) / 16.0);
let follow = self
.fx
.follow(key, camera + target.as_dvec3(), motion.head_yaw, c.lag);
let clearance = (horizon_cosmetics::HEAD_CLEARANCE + c.reach) / 16.0 * scale;
let mut rel = (follow.pos - at.camera).as_vec3();
if let Some((forward, left)) = first_person {
rel = follow::out_of_view(rel, forward, left);
let mut rel = (follow.pos - camera).as_vec3();
if first_person {
rel = follow::out_of_view(rel, Vec3::from(view.forward), Vec3::from(view.left));
}
rel = follow::clear_of(rel, head, clearance);
Some((model, uv, companion_matrix(rel, follow.yaw, scale)))
}
/// The companion of wearer `key` moved toward `target` (once a frame, however many times it
/// is drawn).
fn companion_follow(&self, key: u32, target: DVec3, yaw: f32, lag: f32) -> Follow {
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let dt = fx.dt;
let (f, mark) = fx
.companions
.entry(key)
.or_insert((Follow::at(target, yaw), frame));
if *mark != frame {
f.step(target, yaw, lag, dt);
*mark = frame;
}
*f
}
/// The companion's animations, and its `look` part turned with the wearer's pitch.
fn pose_companion(&self, item: &Item, m: &Mob, alpha: f32, poses: &mut Poses, model: &Part) {
poses.reset(model);
item.animate(m.age as f32 + alpha, |name, pose| *poses.get(name) = pose);
if let Some(look) = item.companion.as_ref().and_then(|c| c.look.as_deref()) {
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * alpha;
poses.get(look)[3] += (pitch * 0.6).clamp(-30.0, 30.0).to_radians();
}
let matrix = companion_matrix(rel, follow.yaw, scale).to_cols_array_2d();
let near = if first_person {
Light::AtPlace
} else {
Light::Model
};
let look = Look::new(uv).lit(glowing(c.glow, near));
let ticks = draw.ticks();
// Its animations, and its `look` part turned with the wearer's pitch.
let pitch = (motion.head_pitch * 0.6).clamp(-30.0, 30.0).to_radians();
draw.draw(&mesh, matrix, look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose);
if let Some(part) = c.look.as_deref() {
p.pose(part)[3] += pitch;
}
});
}
/// The trail's particles: spawned once a frame by the distance walked (not while sneaking or
/// asleep), facing the camera.
fn trail(&self, at: &Wearer, item: &Arc<Item>) {
fn trail(&mut self, key: u32, item: &Arc<Item>, uv: [f32; 4], draw: &mut dyn PlayerDraw) {
let Some(t) = &item.trail else {
return;
};
let Some((_, uv)) = self.cosmetics_baked(item) else {
return;
};
let m = at.m;
let a = f64::from(at.alpha);
let feet = DVec3::new(
m.previous.x + (m.position.x - m.previous.x) * a,
m.previous.y + (m.position.y - m.previous.y) * a,
m.previous.z + (m.position.z - m.previous.z) * a,
);
let moves = !m.extra.crouching && !asleep(m);
let q = self.camera_orientation();
let (right, up) = (q * Vec3::X, q * Vec3::Y);
let light = if t.glow { FULL_LIGHT } else { at.light };
let frame = self.cosmetics.frame.get();
let mut fx = self.cosmetics.fx.borrow_mut();
let super::Fx {
let (motion, view) = (draw.motion(), draw.view());
let moves = !motion.crouching && !motion.asleep;
let Fx {
trails,
budget,
now,
clear,
frame,
particles,
..
} = &mut *fx;
} = &mut self.fx;
let (e, mark) = trails
.entry(at.key)
.entry(key)
.or_insert_with(|| (Emitter::default(), frame.wrapping_sub(1)));
if *mark != frame {
e.step(t, feet, moves, *now, budget);
*mark = frame;
}
for quad in e.quads(t, *now, at.camera, right, up) {
let color = ((quad.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
for (corner, [u, v]) in quad.corners.iter().zip(quad.uv) {
clear.push(Vertex {
pos: corner.to_array(),
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
layer: 0,
color,
light,
// Unlit: a particle keeps its colour whichever way it faces.
normal: [0, 0, 0, 0],
glint_uv: [0.0, 0.0],
});
}
if *mark != *frame {
e.step(t, DVec3::from(motion.feet), moves, *now, budget);
*mark = *frame;
}
let (camera, right, up) = (
DVec3::from(view.camera),
Vec3::from(view.right),
Vec3::from(view.up),
);
particles.clear();
particles.extend(
e.quads(t, *now, camera, right, up)
.map(|q| Particle::new(q.corners.map(|c| c.to_array()), q.uv, q.alpha)),
);
draw.particles(uv, particles, glowing(t.glow, Light::Model));
}
/// The aura at the wearer's feet, see-through. None while they fly, swim or sleep: it would
/// tip over with the body.
fn aura(&self, at: &Wearer, item: &Arc<Item>, poses: &mut Poses) {
let (m, Some(a)) = (at.m, &item.aura) else {
fn aura(&mut self, item: &Arc<Item>, uv: [f32; 4], base: Mat4, draw: &mut dyn PlayerDraw) {
let Some(a) = &item.aura else {
return;
};
if m.extra.fall_flying || asleep(m) || m.extra.swim.amount(at.alpha) > 0.0 {
let motion = draw.motion();
if motion.flying || motion.asleep || motion.swimming {
return;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
return;
};
let Some((_, model)) = models.first() else {
let Some((_, mesh)) = self.kept(item).meshes.first().cloned() else {
return;
};
// The model's origin is the middle of the feet: 24 pixels under the neck.
let matrix = at.base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
poses.reset(model);
item.animate(m.age as f32 + at.alpha, |name, pose| {
*poses.get(name) = pose
let matrix = base * Mat4::from_translation(Vec3::new(0.0, 24.0 / 16.0, 0.0));
let look = Look::new(uv)
.lit(glowing(a.glow, Light::Model))
.see_through(a.alpha);
let ticks = draw.ticks();
draw.draw(&mesh, matrix.to_cols_array_2d(), look, &mut |p| {
item.animate(ticks, |name, pose| *p.pose(name) = pose)
});
let light = if a.glow { FULL_LIGHT } else { at.light };
let mut fx = self.cosmetics.fx.borrow_mut();
let start = fx.clear.len();
horizon_client::mobs::render_model(
model,
matrix,
&|p: &Part| poses.pose(p),
uv,
light,
0,
&mut fx.clear,
);
let color = ((a.alpha * 255.0) as u32) << 24 | 0x00FF_FFFF;
for v in &mut fx.clear[start..] {
v.color = color;
// Seen through, a face turned away would add to the near one (and a card's two
// faces would double it): `entity.wgsl` drops back faces, so it is `alpha` at most.
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
}
}
/// After every player: the player's own companion in first person (out of the middle of the
/// view, after the first-person model went to the shadow pass only), then the frame's trails
/// and auras in one see-through span.
pub(in crate::game) fn cosmetics_fx_geometry(
&self,
camera: DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
if !super::enabled() {
return;
}
if !self.own_body_in_view()
&& let Some(m) = self.player_model.as_ref()
{
self.mark_entity(out.len(), "minecraft:player");
self.own_companion_first_person(m, camera, alpha as f32, out);
}
let fx = self.cosmetics.fx.borrow();
if !fx.clear.is_empty() {
self.mark_translucent_entity(out.len(), "minecraft:player");
out.extend_from_slice(&fx.clear);
}
}
fn own_companion_first_person(
&self,
m: &Mob,
camera: DVec3,
alpha: f32,
out: &mut Vec<Vertex>,
) {
if horizon_client::mobs::trader::is_invisible(m) || self.own_invisible() {
return;
}
let Some(worn) = self.worn(m, true) else {
return;
};
let Some(item) = worn.iter().find(|i| i.slot == Slot::Companion) else {
return;
};
let at = Wearer {
m,
own: true,
key: OWN,
base: horizon_client::mobs::render::base_transform(m, alpha, camera),
camera,
alpha,
light: FULL_LIGHT,
};
let (yaw, pitch) = (self.yaw.to_radians(), self.pitch.to_radians());
let forward = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
let left = Vec3::new(yaw.cos(), 0.0, yaw.sin());
let Some((model, uv, matrix)) = self.companion_at(&at, item, Some((forward, left))) else {
return;
};
// Lit where it floats.
let p = (camera + matrix.w_axis.truncate().as_dvec3()).floor();
let light = self.world.light(p.x as i32, p.y as i32, p.z as i32);
let mut light = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
if item.companion.as_ref().is_some_and(|c| c.glow) {
light = FULL_LIGHT;
}
let mut poses = self.cosmetics.poses.borrow_mut();
let poses = &mut poses.1;
self.pose_companion(item, m, alpha, poses, &model);
self.cosmetics_model(&model, matrix, poses, uv, light, false, None, out);
}
}
@@ -0,0 +1,285 @@
//! The cosmetics' settings (spec #14091 step 5a): their lines of `horizon-extras.txt`, the ones
//! the launcher's Wardrobe shares (`horizon_cosmetics::prefs`), through the settings store
//! ([`STORE`]), and their rows in the Extras page's Cosmetics group ([`SECTION`]). Each
//! splitscreen player has their own, as their own settings file: they live in the player's
//! [`Seat`].
use super::{Seat, service};
use horizon_cosmetics::prefs::{self, Others};
use horizon_hooks::pages::{Act, Label, Page, Place, Row};
use horizon_hooks::settings::Store;
/// My cosmetics in single player too.
const SOLO: &str = "cosmeticsSolo";
/// The settings, each with its line in `horizon-extras.txt`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Settings {
/// Show my cosmetics (the account's, told to the service; `prefs::MINE`).
pub mine: bool,
/// How much of other players' cosmetics shows: no line until the player chooses
/// (`Seat::others`).
pub others: Option<Others>,
/// Other players' trails and auras hidden.
pub hide_fx: bool,
/// My cosmetics in single player too: the window's own server names the player by their
/// offline profile, which the service doesn't know, so they show only with this on.
pub solo: bool,
/// Horizon's mark before Horizon players' names (name tags, player list).
pub marks: bool,
/// The service may say this player plays Horizon.
pub listed: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
mine: true,
others: None,
hide_fx: false,
solo: false,
marks: true,
listed: true,
}
}
}
const KEYS: [&str; 6] = [
prefs::MINE,
prefs::OTHERS,
prefs::HIDE_FX,
SOLO,
prefs::MARKS,
prefs::LISTED,
];
impl Settings {
/// One line; a value it can't read keeps the setting.
fn load(&mut self, key: &str, value: &str) {
let flag = |was: bool| match value {
"true" => true,
"false" => false,
_ => was,
};
match key {
prefs::MINE => self.mine = flag(self.mine),
// `Others::parse` reads the old two-way switch too.
prefs::OTHERS => self.others = Others::parse(value).or(self.others),
prefs::HIDE_FX => self.hide_fx = flag(self.hide_fx),
SOLO => self.solo = flag(self.solo),
prefs::MARKS => self.marks = flag(self.marks),
prefs::LISTED => self.listed = flag(self.listed),
_ => {}
}
}
/// Their lines; others' only once chosen.
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
let text = |on: bool| if on { "true" } else { "false" };
line(prefs::MINE, text(self.mine));
if let Some(others) = self.others {
line(prefs::OTHERS, others.name());
}
line(prefs::HIDE_FX, text(self.hide_fx));
line(SOLO, text(self.solo));
line(prefs::MARKS, text(self.marks));
line(prefs::LISTED, text(self.listed));
}
}
/// The settings' lines of `horizon-extras.txt`, the player's own file in splitscreen: the game
/// loads and saves it on the player's turn, so they are that seat's.
pub(super) static STORE: Lines = Lines;
pub(super) struct Lines;
impl Store for Lines {
fn file(&self) -> &'static str {
prefs::FILE
}
fn owns(&self, key: &str) -> bool {
KEYS.contains(&key)
}
fn load(&self, key: &str, value: &str) {
service(|s| s.settings.load(key, value));
}
fn save(&self, line: &mut dyn FnMut(&str, &str)) {
service(|s| s.settings).save(line);
}
fn is_default(&self) -> bool {
service(|s| s.settings) == Settings::default()
}
}
/// The rows, top to bottom.
const ROWS: [&str; 6] = [
"horizon.extras.cosmetics_mine",
"horizon.extras.cosmetics_solo",
"horizon.extras.cosmetics_others",
"horizon.extras.cosmetics_hide_fx",
"horizon.extras.horizon_marks",
"horizon.extras.horizon_me",
];
const TOOLTIPS: [&str; 6] = [
"horizon.extras.cosmetics_mine.tooltip",
"horizon.extras.cosmetics_solo.tooltip",
"horizon.extras.cosmetics_others.tooltip",
"horizon.extras.cosmetics_hide_fx.tooltip",
"horizon.extras.horizon_marks.tooltip",
"horizon.extras.horizon_me.tooltip",
];
/// The rows in the Extras page's Cosmetics group, while cosmetics are offered
/// ([`super::enabled`]).
pub(super) static SECTION: Section = Section;
pub(super) struct Section;
impl Section {
fn rows_of(seat: &Seat, rows: &mut Vec<Row>) {
let s = &seat.settings;
let shown = |on: bool| {
Label::Key(if on {
"horizon.extras.cosmetics.shown"
} else {
"horizon.extras.cosmetics.hidden"
})
};
let others = Label::Key(match seat.others() {
Others::Shown => "horizon.extras.cosmetics.shown",
Others::NameTags => "horizon.extras.cosmetics.name_tags",
Others::Hidden => "horizon.extras.cosmetics.hidden",
});
let toggle = |label, on| Row::Toggle { label, on };
let cycle = |label, value| Row::Cycle { label, value };
rows.extend([
cycle(ROWS[0], shown(s.mine)),
toggle(ROWS[1], s.solo),
cycle(ROWS[2], others),
toggle(ROWS[3], s.hide_fx),
toggle(ROWS[4], s.marks),
toggle(ROWS[5], s.listed),
]);
}
/// A press on row `row`; `back` turns the three-way row the other way.
fn act_on(seat: &mut Seat, row: usize, back: bool) {
let s = &mut seat.settings;
match row {
0 => s.mine = !s.mine,
1 => s.solo = !s.solo,
2 => {
// From what the row shows; the result is the player's choice from now on.
let o = seat.others();
seat.settings.others = Some(if back { o.next().next() } else { o.next() });
}
3 => s.hide_fx = !s.hide_fx,
4 => s.marks = !s.marks,
5 => s.listed = !s.listed,
_ => {}
}
}
}
impl Page for Section {
fn id(&self) -> &'static str {
"horizon-cosmetics.extras"
}
fn title(&self) -> &'static str {
"horizon.extras.header.cosmetics"
}
fn place(&self) -> Place {
Place::Extras
}
fn rows(&self, rows: &mut Vec<Row>) {
if super::enabled() {
service(|seat| Self::rows_of(seat, rows));
}
}
fn act(&self, row: usize, act: Act) {
let back = match act {
Act::Press => false,
Act::PressBack => true,
_ => return,
};
service(|seat| Self::act_on(seat, row, back));
}
fn tooltip(&self, row: usize) -> Option<Label> {
TOOLTIPS.get(row).copied().map(Label::Key)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn loaded(text: &str) -> Settings {
let mut s = Settings::default();
for (key, value) in text.lines().filter_map(|l| l.split_once(':')) {
s.load(key.trim(), value.trim());
}
s
}
fn saved(s: &Settings) -> String {
let mut text = String::new();
s.save(&mut |k, v| text.push_str(&format!("{k}:{v}\n")));
text
}
#[test]
fn the_lines_round_trip_and_the_wardrobe_reads_them() {
let s = Settings {
mine: false,
others: Some(Others::NameTags),
hide_fx: true,
solo: true,
marks: false,
listed: false,
};
let text = saved(&s);
assert_eq!(loaded(&text), s);
// The launcher's Wardrobe reads the same lines.
let wardrobe = prefs::Prefs::parse(&text);
assert_eq!(
(wardrobe.mine, wardrobe.others, wardrobe.hide_fx),
(false, Some(Others::NameTags), true)
);
// No choice made: no `cosmeticsOthers` line; the old switch still reads.
assert!(!saved(&Settings::default()).contains(prefs::OTHERS));
assert_eq!(
loaded("cosmeticsOthers:false\n").others,
Some(Others::Hidden)
);
// A value it can't read keeps the setting.
assert!(loaded("cosmeticsMine:maybe\n").mine);
}
#[test]
fn others_cycle_three_ways_from_what_the_row_shows() {
let mut seat = Seat::default();
// Not chosen, nothing owned: Hidden, and forward wraps to Shown.
assert_eq!(seat.others(), Others::Hidden);
Section::act_on(&mut seat, 2, false);
assert_eq!(seat.settings.others, Some(Others::Shown));
Section::act_on(&mut seat, 2, true);
assert_eq!(seat.settings.others, Some(Others::Hidden));
Section::act_on(&mut seat, 2, true);
assert_eq!(seat.settings.others, Some(Others::NameTags));
// Owning one shows them by default, until the player chooses.
let mut owner = Seat::default();
owner.owner.owns = true;
assert_eq!(owner.others(), Others::Shown);
let mut rows = Vec::new();
Section::rows_of(&owner, &mut rows);
assert_eq!(
rows[2],
Row::Cycle {
label: ROWS[2],
value: Label::Key("horizon.extras.cosmetics.shown")
}
);
assert_eq!(rows.len(), TOOLTIPS.len());
}
}
@@ -9,7 +9,7 @@ impl Game<'_> {
pub(super) fn mapped_key_down(&self, mapping: &str) -> bool {
keybinds::key_code(self.settings.keys.get(mapping))
.is_some_and(|k| self.physical_keys.contains(&k))
|| self.input_holds(mapping)
|| self.pad.holds_action(&self.settings.controller, mapping)
}
/// `CreativeModeInventoryScreen.selectTab` on the HOTBAR tab: each saved
+2 -2
View File
@@ -2317,7 +2317,7 @@ impl Game<'_> {
a.spawn_velocity = Some(p.velocity);
a.spawn_rot = Some((p.pitch, p.yaw));
}
self.creatures.projectiles.put_back(p);
self.creatures.projectiles.push(p);
self.native_projectile_launch(player, &name, id, kind);
}
/// `MushroomCow.shear`'s `sendParticles(EXPLOSION, x, getY(0.5), z, 1, 0, 0, 0, 0)`:
@@ -6949,7 +6949,7 @@ impl Game<'_> {
{
m.heal(25.0);
consume = true;
eat = Some(("entity.iron_golem.repair", 1.0, m.golem_repair_pitch()));
sound = Some("entity.iron_golem.repair");
}
Species::Creeper if creeper_igniter => {
m.extra.flag = true;
@@ -280,25 +280,18 @@ impl Game<'_> {
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
// What crates draw on players and mannequins (`game/player_hooks.rs`: cosmetics),
// posed on the GPU with the mob's own jobs: culled with it.
if !layers_only
&& super::super::cosmetics::enabled()
&& horizon_hooks::engine::any()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
if let Some((lo, hi)) =
self.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
{
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
+1 -4
View File
@@ -1008,10 +1008,7 @@ impl Game<'_> {
p.position.z - center[2],
];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
self.input_feedback(horizon_client::input_source::Feedback::Explosion {
distance,
radius,
});
self.pad_rumble(horizon_client::gamepad::Rumble::Explosion { distance, radius });
}
// Explosion.explode: the blast is heard.
let ctx = horizon_client::sculk::Context::default();
+2 -2
View File
@@ -135,8 +135,8 @@ impl Game<'_> {
}
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
if self.studio.freecam.active {
return self.studio.freecam.fov.clamp(1.0, 179.0);
if self.studio_freecam.active {
return self.studio_freecam.fov.clamp(1.0, 179.0);
}
let fov = self.view.calculate_fov(
partial,
@@ -193,7 +193,7 @@ impl Game<'_> {
// replay player's hands remain visible in first person; freecam has
// no player-view hand.
if !fov::studio_draws_hand(
self.studio.freecam.active,
self.studio_freecam.active,
self.ui.hands_drawn(
self.spectator(),
self.sleeping.is_some(),
+1 -1
View File
@@ -1820,7 +1820,7 @@ impl Game<'_> {
// from carries those, as `save_guest` gives them on leaving. The map
// stays their one owner.
let live = self.guest_attachments.get(&uuid).cloned();
let g = playerdata::with_warden(&mut host.hub.guests[i], self.warden_tracker_tag());
let g = &mut host.hub.guests[i];
let held = live.map(|a| std::mem::replace(&mut g.attachments, a));
playerdata::store(g, &mut self.level, vehicle);
if let Some(held) = held {
@@ -899,32 +899,4 @@ impl Game<'_> {
f.input_clicked(world_recipes!(self), before);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
}
+267 -4
View File
@@ -28,10 +28,6 @@ use horizon_core::{
};
use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::CartIndex;
use index::cart_at;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -191,6 +187,102 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
mod display;
use display::{cart_render_bounds, display_offset, shown_unless_air};
pub(super) use display::{default_display_offset, forget_unreadable_display};
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(super) struct CartIndex {
carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
near: Vec<u32>,
}
impl CartIndex {
fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
/// the push the cart loses (the entity gets a quarter of it back).
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
@@ -2892,6 +2984,177 @@ impl Game<'_> {
.render_state(&scene, state.0, m, light, out);
}
}
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
/// TNT cart.
@@ -1,105 +0,0 @@
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
use super::*;
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(in crate::game) struct CartIndex {
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
pub(super) near: Vec<u32>,
}
impl CartIndex {
pub(super) fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
pub(super) fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
pub(super) fn carts_near(
&mut self,
carts: &[Minecart],
i: usize,
area: Aabb,
out: &mut Vec<u32>,
) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
pub(super) fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
@@ -1,173 +0,0 @@
//! Saving and loading a minecart.
use super::*;
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
+24 -27
View File
@@ -61,49 +61,46 @@ impl Game<'_> {
/// that are gone, and read what the instances follow.
pub(super) fn start_mob_sounds(&mut self) -> Vec<MobSound> {
let game_time = self.world.game_time;
let mut mobs = Vec::new();
let mut bee_indices = Vec::new();
for m in &self.mobs {
if m.removed {
continue;
}
let species = m.species();
if !matches!(
species,
Species::Bee | Species::Guardian | Species::ElderGuardian | Species::Sniffer
) {
continue;
}
let is_bee = species == Species::Bee;
let sound = MobSound {
let mobs: Vec<MobSound> = self
.mobs
.iter()
.filter(|m| {
!m.removed
&& matches!(
m.species(),
Species::Bee
| Species::Guardian
| Species::ElderGuardian
| Species::Sniffer
)
})
.map(|m| MobSound {
id: m.id,
position: [m.position.x, m.position.y, m.position.z],
// Vec3.horizontalDistance.
speed: m.velocity.x.hypot(m.velocity.z),
baby: m.is_baby(),
// NeutralMob.isAngry: the anger end time is ahead.
angry: is_bee
angry: m.species() == Species::Bee
&& bee::get(m).is_some_and(|b| b.anger_end > 0 && b.anger_end - game_time > 0),
silent: m.silent,
attack_scale: guardian::attack_scale(m),
// SnifferSoundInstance.tick.
sniffer_digging: species == Species::Sniffer
sniffer_digging: m.species() == Species::Sniffer
&& m.ai.target.is_none()
&& sniffer::can_play_digging_sound(m),
};
if is_bee {
bee_indices.push(mobs.len());
}
mobs.push(sound);
}
})
.collect();
self.guardian_sounds
.retain(|id| mobs.iter().any(|m| m.id == *id));
self.bee_sounds
.retain(|id, _| mobs.iter().any(|m| m.id == *id));
for &idx in &bee_indices {
let sound = &mobs[idx];
if self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
for sound in &mobs {
let is_bee = self
.mobs
.iter()
.any(|m| m.id == sound.id && m.species() == Species::Bee);
if is_bee && self.bee_sounds.get(&sound.id) != Some(&sound.angry) {
self.bee_sounds.insert(sound.id, sound.angry);
let instance = SoundInstance::bee(sound, sound.angry, random_source::create());
self.audio.play_instance(instance, false);
+6 -9
View File
@@ -300,11 +300,7 @@ impl Game<'_> {
) {
continue;
}
let marks = if player || self.cosmetics_local() {
self.cosmetics_marks(m, Some(m.id) == own)
} else {
Marks::default()
};
let marks = self.hooks_name_marks(m, Some(m.id) == own);
let at = match marks.top {
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
None => p,
@@ -770,10 +766,10 @@ impl Game<'_> {
if background { BACKGROUND } else { 0 },
),
};
if n == last && marks.horizon {
if let Some(mark) = marks.mark.filter(|_| n == last) {
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
// 8 text pixels square, a pixel left of the background.
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
for &(mx, my, w, h, argb) in mark {
let a = text_alpha * (argb >> 24) / 255;
batch.fill(
&self.gui.atlas,
@@ -937,10 +933,11 @@ struct NameTag {
marks: Marks,
}
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
/// What a hook draws by a player's name (`horizon_hooks::players::NameDecoration`): a mark
/// before it (Horizon's: they play Horizon) and a name tag style.
#[derive(Clone, Copy, Default)]
pub(super) struct Marks {
pub horizon: bool,
pub mark: Option<horizon_hooks::players::Mark>,
pub style: Option<TagStyle>,
/// How high the cosmetics drawn on their body get above the neck, in model pixels: the tag
/// goes up over a tall one (`cosmetic_lift`).
+20
View File
@@ -679,6 +679,26 @@ impl Game<'_> {
gamepad::move_vector(self.pad.state.left, settings.move_deadzone)
}
/// Whether `key` is held, a movement key by the left stick too: as the keys the stick holds
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
/// the movement itself.
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
if self.keys.raw(&key) {
return true;
}
let Some(stick) = self.pad_move() else {
return false;
};
let [forward, backward, left, right] = gamepad::eight_direction_keys(stick);
match key {
KeyCode::KeyW => forward,
KeyCode::KeyS => backward,
KeyCode::KeyA => left,
KeyCode::KeyD => right,
_ => false,
}
}
/// The right stick turns the view, once per frame (`Entity.turn`).
pub(super) fn pad_look(&mut self, seconds: f32) {
let settings = &self.settings.controller;
+13 -27
View File
@@ -280,11 +280,11 @@ impl Game<'_> {
/// front of) it, pulled in where visual shapes are in the way. Returns the
/// position, yaw and pitch to render from.
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
if self.studio.freecam.active {
if self.studio_freecam.active {
return Some((
self.studio.freecam.position,
self.studio.freecam.yaw,
self.studio.freecam.pitch,
self.studio_freecam.position,
self.studio_freecam.yaw,
self.studio_freecam.pitch,
));
}
// Minecraft.cameraEntity: a server's spectator camera on a mob or
@@ -311,8 +311,8 @@ impl Game<'_> {
}
};
let mut view = self.view;
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
let pull = self.emote_camera_pull(partial);
// A hook may pull the camera back (an emote, from the player's face).
let pull = self.hooks_camera_distance(partial);
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
@@ -643,9 +643,8 @@ impl Game<'_> {
/// The model's old pitch is the player's `xRotO`, which the turn moves
/// with `xRot` (`turn_rotation`).
pub(super) fn turn_player(&mut self, xo: f64, yo: f64) {
if self.studio.freecam.active {
self.studio
.freecam
if self.studio_freecam.active {
self.studio_freecam
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
return;
}
@@ -698,7 +697,7 @@ impl Game<'_> {
/// `LevelExtractor`'s skip of the camera entity: the player's own body
/// is drawn unless the camera is in first person, or while it sleeps.
pub(super) fn own_body_in_view(&self) -> bool {
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio.freecam.active
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
}
/// Whether the player's own model shows in the world view (its body or,
/// while invisible, only its armour and held items).
@@ -801,19 +800,8 @@ impl Game<'_> {
packed,
out,
);
if super::cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
self.cosmetics_geometry(
m,
true,
base,
Some(camera),
alpha as f32,
packed,
false,
out,
);
}
let world = horizon_hooks::players::Pass::World;
self.own_layers_geometry(m, world, camera, alpha as f32, packed, out);
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
@@ -927,10 +915,8 @@ impl Game<'_> {
240 | 240 << 8,
&mut verts,
);
if super::cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
self.cosmetics_geometry(&m, true, base, None, 1.0, 240 | 240 << 8, false, &mut verts);
}
let picture = horizon_hooks::players::Pass::Picture;
self.own_layers_geometry(&m, picture, camera, 1.0, 240 | 240 << 8, &mut verts);
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
// wings' faces show from both sides (the elytra texture is empty on
// the face that looks at the body).
+6 -77
View File
@@ -101,7 +101,7 @@ impl Game<'_> {
};
// A controller's stick click acts as the key without setting the modifier: it is held
// while the pad holds it (or its release waits for a tick), not when the keyboard says.
let pad = self.input_holds(mapping);
let pad = self.pad.holds_action(&self.settings.controller, mapping);
if !held && !pad {
self.toggles.0[slot].set_down(false, needs_toggle);
self.sync_toggle(slot);
@@ -180,7 +180,7 @@ impl Game<'_> {
raw: KeyCode,
source: Source,
) -> Result<()> {
if self.screen == Screen::Playing && self.studio.replay.is_some() {
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if !pressed {
self.keys.release(&key, source);
return Ok(());
@@ -191,7 +191,7 @@ impl Game<'_> {
}
return Ok(());
}
if !studio_freecam_movement_key(key) || !self.studio.freecam.active {
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
return Ok(());
}
}
@@ -352,7 +352,9 @@ impl Game<'_> {
_ => return false,
};
match source {
Source::Keyboard | Source::Agent => self.input_holds(mapping),
Source::Keyboard | Source::Agent => {
self.pad.holds_action(&self.settings.controller, mapping)
}
Source::Pad => {
let keys = &self.settings.keys;
self.physical_buttons
@@ -448,79 +450,6 @@ impl Game<'_> {
}
}
/// The input source (docs/layers.md, "Controllers, splitscreen, replays and UI pages"): what
/// gameplay asks of a device beyond the keyboard and mouse, and tells it. Gameplay calls these,
/// never the controller modules; today `pad` answers.
impl Game<'_> {
/// The analog move vector (`[left, forward]`, -1..1) while a device moves the player.
pub(super) fn input_move(&self) -> Option<[f32; 2]> {
self.pad_move()
}
/// Whether a device holds the key mapping named `mapping` (`key.use`).
pub(super) fn input_holds(&self, mapping: &str) -> bool {
self.pad.holds_action(&self.settings.controller, mapping)
}
/// Turns the view by what the devices give this frame (`Entity.turn`).
pub(super) fn input_look(&mut self, seconds: f32) {
self.pad_look(seconds);
}
/// The keyboard or mouse acted in play (a key or button pressed): it has control again, though
/// only moving the mouse takes a pad out of use.
pub(super) fn input_keyboard_used(&mut self) {
self.pad.keyboard_took_over = true;
}
/// Whether the pointer event being handled is one a device made (a pad's menu cursor
/// clicking), not the mouse's.
pub(super) fn input_made_pointer_event(&self) -> bool {
self.pad.menu.synthetic
}
/// A new player entity took over (spawn, respawn, a server joined, a dimension change).
pub(super) fn input_player_replaced(&mut self) {
self.pad.player_replaced();
}
/// Whether the drop about to happen is a device's whole-stack drop (it has no Control key to
/// hold). Asking answers once.
pub(super) fn input_takes_stack_drop(&mut self) -> bool {
std::mem::take(&mut self.pad.drop_stack)
}
/// The HUD starts a frame: what a device drew on the last one is gone.
pub(super) fn input_hud_begin(&mut self) {
self.pad.guide_shown.clear();
}
/// Something happened to the player that a device may let them feel.
pub(super) fn input_feedback(&mut self, event: horizon_client::input_source::Feedback) {
self.pad_rumble(event.into());
}
/// Whether `key` is held, a movement key by the analog move vector too: as the keys it holds
/// (`eight_direction_keys`), whatever moves the player. For readers of the keys that aren't
/// the movement itself.
pub(super) fn movement_key_held(&self, key: KeyCode) -> bool {
if self.keys.raw(&key) {
return true;
}
let Some(stick) = self.input_move() else {
return false;
};
let [forward, backward, left, right] = horizon_client::gamepad::eight_direction_keys(stick);
match key {
KeyCode::KeyW => forward,
KeyCode::KeyS => backward,
KeyCode::KeyA => left,
KeyCode::KeyD => right,
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::{studio_freecam_movement_key, studio_replay_shortcut};
+8 -7
View File
@@ -10,7 +10,7 @@ impl Game<'_> {
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
self.direct_craft_stop();
if self.screen == Screen::Playing && self.studio.replay.is_some() {
if self.screen == Screen::Playing && self.studio_replay.is_some() {
match key {
KeyCode::Escape => {
self.close_studio_replay();
@@ -25,19 +25,19 @@ impl Game<'_> {
KeyCode::KeyF => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::ToggleFreecam,
),
KeyCode::KeyT => self.studio.timeline.visible = !self.studio.timeline.visible,
KeyCode::KeyT => self.studio_timeline.visible = !self.studio_timeline.visible,
KeyCode::KeyM => self.add_studio_marker(),
KeyCode::Home => self.handle_studio_topbar_action(
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
),
KeyCode::KeyL => {
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
}
KeyCode::KeyK => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::AddCameraKeyframe,
),
KeyCode::Delete => {
if let Some(index) = self.studio.timeline.selected_keyframe {
if let Some(index) = self.studio_timeline.selected_keyframe {
self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
);
@@ -78,7 +78,7 @@ impl Game<'_> {
KeyCode::Escape => match self.screen {
Screen::Options => self.options_key(KeyCode::Escape, None)?,
Screen::Playing => {
if self.studio.replay.is_some() && self.captured {
if self.studio_replay.is_some() && self.captured {
self.capture(false);
return Ok(());
}
@@ -213,7 +213,7 @@ impl Game<'_> {
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
KeyCode::F11 => self.toggle_fullscreen(),
KeyCode::F5 if self.screen == Screen::Playing && self.studio.replay.is_none() => {
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
self.cycle_camera()
}
KeyCode::KeyE
@@ -279,7 +279,8 @@ impl Game<'_> {
KeyCode::KeyQ if self.screen == Screen::Playing => {
// handleKeybinds: the number keys select before key.drop.
self.select_queued_hotbar();
let all = drop_whole_stack(self.modifiers) || self.input_takes_stack_drop();
let all =
drop_whole_stack(self.modifiers) || std::mem::take(&mut self.pad.drop_stack);
self.drop_held(all);
}
KeyCode::Digit1
@@ -0,0 +1,613 @@
//! Where the game hands the player models to crates above the engine (`horizon_hooks::Hooks`'s
//! `frame`, `tick`, `name_decoration`, `player_mark`, `camera`; spec #14091): only a window's
//! game calls them, never the integrated server's thread or a dedicated server. With no hook
//! registered nothing is built.
use super::Game;
use horizon_client::fast_hash::FxHashMap;
use horizon_client::join_seq::JoinList;
use horizon_client::mesher::Vertex;
use horizon_client::mobs::anim::{self, BodyPose, PooledPoses, Poses};
use horizon_client::mobs::{Mob, Part, Polygon, render};
use horizon_hooks::engine;
use horizon_hooks::players::{
Bone, CameraView, Frame, Gear, Kind, Light, Look, Mesh, Motion, PartPoses, Particle, Pass,
PlayerDraw, PlayerModel, PlayerModels, SpriteStore, Sprites, View,
};
use std::sync::Arc;
/// Registers the hooks of the Horizon parts still in this crate (cosmetics: spec #14091 step 5
/// moves them out). Before the hooks are sealed.
pub(crate) fn register() {
if let Err(e) = super::cosmetics::register() {
log::error!("Cosmetics: their hooks came too late: {e}");
}
}
/// The models of the world a window shows: its own first, then the other players and mannequins.
struct Models<'a> {
own: Option<&'a mut Mob>,
mobs: &'a mut JoinList<Mob>,
remote: &'a super::remote::Remote,
}
impl Models<'_> {
fn find(&mut self, id: u32) -> Option<&mut Mob> {
match self.own.as_deref_mut() {
Some(m) if m.id == id => Some(m),
_ => self
.mobs
.iter_mut()
.find(|m| m.id == id && kind(m).is_some()),
}
}
}
/// What kind of player model `m` is, if it is one (the window's own model aside).
fn kind(m: &Mob) -> Option<Kind> {
match &*m.kind.id {
"minecraft:player" => Some(Kind::Player),
"minecraft:mannequin" => Some(Kind::Mannequin),
_ => None,
}
}
/// What `m` wears where a crate may draw: a helmet or head item, a chestplate, a cape or elytra.
pub(super) fn gear(m: &Mob) -> Gear {
let elytra = m.extra.chest.as_deref() == Some("minecraft:elytra");
Gear::new(
m.extra.head.is_some(),
m.extra.chest.is_some() && !elytra,
m.cape.is_some() || elytra,
)
}
fn model(m: &Mob, kind: Kind, uuid: Option<u128>) -> PlayerModel {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
PlayerModel::new(m.id, kind, uuid, m.age, dx * dx + dz * dz > 1e-4)
.looks(gear(m), horizon_client::mobs::trader::is_invisible(m))
}
impl PlayerModels for Models<'_> {
fn connection(&self) -> u64 {
self.remote.session.serial()
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
if let Some(m) = self.own.as_deref() {
f(model(m, Kind::Own, Some(self.remote.profile())));
}
for m in self.mobs.iter() {
if let Some(kind) = kind(m) {
let uuid = (kind == Kind::Player)
.then(|| self.remote.player_uuid(m.id))
.flatten();
f(model(m, kind, uuid));
}
}
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
match self.own.as_deref() {
Some(m) if m.id == id => m.emote.as_ref(),
_ => self
.mobs
.iter()
.find(|m| m.id == id && kind(m).is_some())?
.emote
.as_ref(),
}
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some(m) = self.find(id) {
m.emote = pose;
}
}
}
/// Sprite `id`'s place in the atlas's names (`hooks/<hex>`), written into `buf`: no allocation.
fn location(id: u64, buf: &mut [u8; 22]) -> &str {
use std::io::Write;
let mut out = std::io::Cursor::new(&mut buf[..]);
let _ = write!(out, "hooks/{id:x}");
let len = out.position() as usize;
std::str::from_utf8(&buf[..len]).unwrap_or_default()
}
/// The crates' sprites in the GUI atlas, in the skin slots: read while drawing.
struct HookSprites<'a, 'g>(&'a Game<'g>);
impl Sprites for HookSprites<'_, '_> {
fn get(&self, id: u64) -> Option<[f32; 4]> {
let mut buf = [0; 22];
let at = location(id, &mut buf);
let r = self.0.gui.atlas.sprites.get(at)?;
// Kept this frame (`skin_slots.next_frame`).
self.0.skin_downloads.borrow_mut().drawn(at);
Some([r.u0, r.v0, r.u1, r.v1])
}
}
/// Where a crate's frame hook puts its sprites.
struct HookStore<'a, 'g>(&'a mut Game<'g>);
impl SpriteStore for HookStore<'_, '_> {
fn has(&self, id: u64) -> bool {
self.0
.gui
.atlas
.sprites
.contains_key(location(id, &mut [0; 22]))
}
fn put(&mut self, id: u64, rgba: Vec<u8>) {
let at = location(id, &mut [0; 22]).to_owned();
self.0.store_skin(at, rgba);
}
}
thread_local! {
/// The crates' meshes as the renderer takes them, by the mesh's address (each entry holds its
/// mesh, so the address isn't reused); render thread only.
static MESHES: std::cell::RefCell<FxHashMap<usize, (Arc<Mesh>, Arc<Part>)>> =
Default::default();
}
/// The game's model of `mesh`, made once.
fn part_of(mesh: &Arc<Mesh>) -> Arc<Part> {
fn part(m: &Mesh) -> Part {
Part {
name: m.name.clone(),
pose: m.pose,
polygons: m
.quads
.iter()
.map(|q| Polygon {
normal: q.normal,
vertices: q.vertices,
cull_back: false,
})
.collect(),
children: m.children.iter().map(part).collect(),
}
}
MESHES.with_borrow_mut(|meshes| {
let (_, part_of) = meshes
.entry(Arc::as_ptr(mesh) as usize)
.or_insert_with(|| (mesh.clone(), Arc::new(part(mesh))));
part_of.clone()
})
}
/// Full light, packed as the vertices carry it.
const FULL: u32 = 240 | 240 << 8;
thread_local! {
/// What crates draw see-through this frame, added after every player.
static SEE_THROUGH: std::cell::RefCell<Vec<Vertex>> = const { std::cell::RefCell::new(Vec::new()) };
}
/// Forgets the meshes no crate holds any more (once a frame).
fn forget_meshes() {
let _ = MESHES.try_with(|m| {
m.borrow_mut()
.retain(|_, (mesh, _)| Arc::strong_count(mesh) > 1)
});
}
/// `Poses` as a crate poses a mesh's parts.
struct PosesOf<'a>(&'a mut Poses);
impl PartPoses for PosesOf<'_> {
fn pose(&mut self, name: &str) -> &mut [f32; 9] {
self.0.get(name)
}
}
/// A player model drawn, as a crate draws on it: posed on the GPU's skin path
/// when `gpu` (the mob pass's skin frame, begun; the bounds of what went there grow, for
/// culling), else its vertices go to `out`.
struct DrawPlayer<'a, 'g> {
game: &'a Game<'g>,
m: &'a Mob,
pass: Pass,
/// The model's matrix (`render::base_transform`).
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
out: &'a mut Vec<Vertex>,
bounds: Option<(glam::Vec3, glam::Vec3)>,
/// The body as it is drawn, posed on the first `bone`.
body: Option<PooledPoses>,
poses: PooledPoses,
}
impl DrawPlayer<'_, '_> {
fn body(&self) -> &Part {
let (key, _) = render::body(self.m);
self.m.kind.layer(&key).unwrap_or(&self.m.kind.model)
}
/// `light` packed; `at`: the place `Light::AtPlace` reads, relative to the camera.
fn packed(&self, light: Light, at: glam::Vec3) -> u32 {
match light {
Light::Full => FULL,
Light::AtPlace => {
let p = (self.camera + at.as_dvec3()).floor();
let l = self.game.world.light(p.x as i32, p.y as i32, p.z as i32);
(u32::from(l >> 4) * 16) | ((u32::from(l & 15) * 16) << 8)
}
_ => self.light,
}
}
}
impl PlayerDraw for DrawPlayer<'_, '_> {
fn pass(&self) -> Pass {
self.pass
}
fn ticks(&self) -> f32 {
self.m.age as f32 + self.alpha
}
fn base(&self) -> [[f32; 4]; 4] {
self.base.to_cols_array_2d()
}
fn motion(&self) -> Motion {
let (m, a) = (self.m, self.alpha);
let t = f64::from(a);
let (p, n) = (m.previous, m.position);
let feet = [
p.x + (n.x - p.x) * t,
p.y + (n.y - p.y) * t,
p.z + (n.z - p.z) * t,
];
let turn = horizon_client::mobs::living::wrap(m.head_yaw - m.previous_head_yaw);
let yaw = m.previous_head_yaw + turn * a;
let pitch = m.previous_head_pitch + (m.head_pitch - m.previous_head_pitch) * a;
let asleep = m.extra.avatar_pose == horizon_client::pose::Pose::Sleeping;
let swimming = m.extra.swim.amount(a) > 0.0;
Motion::new(feet, yaw, pitch).poses(
m.extra.crouching,
asleep,
m.extra.fall_flying,
swimming,
)
}
fn view(&self) -> View {
let q = self.game.camera_orientation();
let (right, up) = (
(q * glam::Vec3::X).to_array(),
(q * glam::Vec3::Y).to_array(),
);
let (yaw, pitch) = (self.game.yaw.to_radians(), self.game.pitch.to_radians());
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let left = [yaw.cos(), 0.0, yaw.sin()];
View::new(self.camera.to_array(), right, up).looking(forward, left)
}
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
if self.pass == Pass::FirstPerson {
return None;
}
if self.body.is_none() {
let mut poses = Poses::pooled();
poses.reset(self.body());
let data = self.game.res.mob_data();
let state = anim::state(self.m, self.alpha);
anim::setup(self.m, &state, &mut poses, &data.animations);
self.body = Some(poses);
}
let poses = self.body.as_ref()?;
let at = render::part_matrix(self.body(), self.base, poses, bone.part(), 0.0)?;
Some(at.to_cols_array_2d())
}
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>> {
self.m.emote.as_ref()
}
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
HookSprites(self.game).get(id)
}
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
look: Look,
pose: &mut dyn FnMut(&mut dyn PartPoses),
) {
let model = part_of(mesh);
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
let (uv, light) = (look.uv, self.packed(look.light, matrix.w_axis.truncate()));
self.poses.reset(&model);
pose(&mut PosesOf(&mut self.poses));
let poses = &self.poses;
if let Some(alpha) = look.see_through {
let color = ((alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let pose_of = |p: &Part| poses.pose(p);
SEE_THROUGH.with_borrow_mut(|out| {
let start = out.len();
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, out);
for v in &mut out[start..] {
v.color = color;
// Seen through, a face turned away would add to the near one: `entity.wgsl`
// drops back faces, so it is `alpha` at most.
v.normal[3] = horizon_client::mesher::SHADED_CULL_W;
}
});
return;
}
if self.gpu {
let mut models = self.game.skin_models.borrow_mut();
let mut frame = self.game.skin_frame.borrow_mut();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = self.bounds;
let paint = horizon_client::mobs::skin::Paint {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light,
overlay: 0,
};
if skin.draw(&model, matrix, poses, paint) {
self.bounds = skin.bounds;
return;
}
}
let pose_of = |p: &Part| poses.pose(p);
horizon_client::mobs::render_model(&model, matrix, &pose_of, uv, light, 0, self.out);
}
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light) {
let light = self.packed(light, glam::Vec3::ZERO);
SEE_THROUGH.with_borrow_mut(|out| {
for q in particles {
let color = ((q.alpha.clamp(0.0, 1.0) * 255.0) as u32) << 24 | 0x00FF_FFFF;
for (corner, [u, v]) in q.corners.iter().zip(q.uv) {
out.push(Vertex {
pos: *corner,
uv: [uv[0] + (uv[2] - uv[0]) * u, uv[1] + (uv[3] - uv[1]) * v],
layer: 0,
color,
light,
// Unlit: a particle keeps its colour whichever way it faces.
normal: [0, 0, 0, 0],
glint_uv: [0.0, 0.0],
});
}
}
});
}
}
impl Game<'_> {
/// What crates draw on player model `m` in the world pass (`creatures.rs`): see
/// [`DrawPlayer`]. The bounds of what went to the GPU.
#[allow(clippy::too_many_arguments)]
pub(super) fn player_layers_geometry(
&self,
m: &Mob,
base: glam::Mat4,
camera: glam::DVec3,
alpha: f32,
light: u32,
gpu: bool,
out: &mut Vec<Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let kind = kind(m).filter(|_| engine::any())?;
let model = model(m, kind, self.remote_player_uuid(m.id));
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
camera,
alpha,
light,
gpu,
out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
draw.bounds
}
/// What crates draw on the window's own model `m` (its vertices to `out`): in the world in
/// third person (`Pass::World`), or in the inventory's picture of it (`Pass::Picture`).
/// `camera`: the world view's camera, or the picture's.
pub(super) fn own_layers_geometry(
&self,
m: &Mob,
pass: Pass,
camera: glam::DVec3,
alpha: f32,
light: u32,
out: &mut Vec<Vertex>,
) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
let model = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let mut draw = DrawPlayer {
game: self,
m,
pass,
base: render::base_transform(m, alpha, camera),
camera,
alpha,
light,
gpu: false,
out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
}
/// After every player (`game.rs`): what crates draw around the window's own model in first
/// person, then what was drawn see-through, in one span.
pub(super) fn players_after_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
if engine::any()
&& !self.own_body_in_view()
&& let Some(m) = self.player_model.as_ref()
{
let own = model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile()));
let own = own.looks(own.gear, own.invisible || self.own_invisible());
self.mark_entity(out.len(), "minecraft:player");
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::FirstPerson,
base: render::base_transform(m, alpha as f32, camera),
camera,
alpha: alpha as f32,
light: FULL,
gpu: false,
out: &mut *out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::first_person(&own, &mut draw);
}
let _ = SEE_THROUGH.try_with(|v| {
let mut v = v.borrow_mut();
if !v.is_empty() {
self.mark_translucent_entity(out.len(), "minecraft:player");
out.extend_from_slice(&v);
v.clear();
}
});
}
/// Once a frame, before drawing.
pub(super) fn client_hooks_frame(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
if !engine::any() {
return;
}
self.cosmetics_say();
let connection = self.remote.as_ref().map_or(0, |r| r.session.serial());
let frame = Frame::new(connection, self.integrated.is_some());
engine::frame(&frame, &mut HookStore(self));
forget_meshes();
let _ = SEE_THROUGH.try_with(|v| v.borrow_mut().clear());
self.cosmetics_frame();
}
/// Once a client tick in a world: the player's own emote, then the hooks.
pub(super) fn client_hooks_tick(&mut self) {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
self.tick_emotes();
if !engine::any() {
return;
}
self.cosmetics_say();
let Some(remote) = self.remote.as_ref() else {
return;
};
engine::tick(&mut Models {
own: self.player_model.as_mut(),
mobs: &mut self.mobs,
remote,
});
}
/// What goes by `m`'s name (`own`: the window's own player), from the hooks.
pub(super) fn hooks_name_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let mut marks = super::names::Marks::default();
if !engine::any() {
return marks;
}
let model = match (own, kind(m)) {
(true, _) => model(m, Kind::Own, self.remote.as_ref().map(|r| r.profile())),
(false, Some(kind)) => model(m, kind, self.remote_player_uuid(m.id)),
(false, None) => return marks,
};
if let Some(d) = engine::name_decoration(&model, &HookSprites(self)) {
marks.mark = d.mark;
marks.top = d.top;
marks.style = d.badge.map(|b| {
let [u0, v0, u1, v1] = b.uv;
super::names::TagStyle {
mark: horizon_client::hud::Rect {
u0,
v0,
u1,
v1,
w: 8,
h: 8,
},
line: b.line,
}
});
}
marks
}
/// How far the third-person camera stands back, as a share of the usual distance: a hook's
/// (an emote pulls it back), else 1.
pub(super) fn hooks_camera_distance(&self, partial: f32) -> f32 {
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
match &self.player_model {
Some(m) if engine::any() => {
engine::camera(&CameraView::new(m.age as f32 + partial)).map_or(1.0, |c| c.distance)
}
_ => 1.0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sprite_locations_fit_the_buffer_and_differ_by_id() {
let mut a = [0; 22];
let mut b = [0; 22];
assert_eq!(location(u64::MAX, &mut a), "hooks/ffffffffffffffff");
assert_eq!(location(10, &mut b), "hooks/a");
}
}
@@ -111,7 +111,7 @@ impl Game<'_> {
self.stop_sounds_for_new_level();
}
self.player = Some(player);
self.input_player_replaced();
self.pad.player_replaced();
self.sway = Default::default();
self.apply_equipment();
self.eye = 1.62;
@@ -26,7 +26,7 @@ impl Game<'_> {
let pointer = self.captured || self.agent_pointer;
let grabbed = pointer
&& self.screen == Screen::Playing
&& (self.player.is_some() || self.studio.freecam.active);
&& (self.player.is_some() || self.studio_freecam.active);
let active = self.focused || self.agent_pointer;
if let Some((xo, yo)) = self
.mouse
@@ -76,8 +76,8 @@ impl Game<'_> {
&& self.control.is_none()
&& self.screen == Screen::Playing
&& !studio_suppresses_focus_pause(
self.studio.replay.is_some(),
self.studio.freecam.active,
self.studio_replay.is_some(),
self.studio_freecam.active,
)
&& self.player.is_some()
&& !self.options.no_pause
@@ -149,7 +149,7 @@ impl Game<'_> {
self.reset_inventory_clicks();
if !capture {
self.stop_mining();
self.studio.rmb_down = false;
self.studio_rmb_down = false;
}
// The real pointer takes over from an agent's.
self.agent_pointer = false;
@@ -353,9 +353,9 @@ impl Game<'_> {
/// click. A screen that is open keeps it free, as releasing the pointer
/// for that screen did: it is the screen, not the focus, that shows it.
pub(super) fn regrab_on_refocus(&mut self) {
if self.studio.replay.is_some() || self.studio.freecam.active {
if self.studio_replay.is_some() || self.studio_freecam.active {
self.regrab_on_refocus = false;
self.studio.rmb_down = false;
self.studio_rmb_down = false;
self.capture(false);
return;
}
+17 -18
View File
@@ -2433,7 +2433,7 @@ impl Game<'_> {
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
if self.studio.replay.is_some() {
if self.studio_replay.is_some() {
return true;
}
self.remote
@@ -6176,7 +6176,7 @@ impl Game<'_> {
player.spectator = self.spectator();
player.velocity = delta;
self.player = Some(player);
self.input_player_replaced();
self.pad.player_replaced();
self.sway = Default::default();
self.eye = 1.62;
self.previous_eye = 1.62;
@@ -6185,7 +6185,7 @@ impl Game<'_> {
self.apply_remote_abilities();
}
}
if self.studio.replay.is_none() {
if self.studio_replay.is_none() {
self.previous_position = position;
}
self.yaw = yaw;
@@ -6441,7 +6441,7 @@ impl Game<'_> {
}
return Ok(());
}
let stick = self.input_move();
let stick = self.pad_move();
if self.player.is_none() {
return Ok(());
}
@@ -6521,7 +6521,7 @@ impl Game<'_> {
return Ok(());
};
player.fluids.boat = boat;
if !riding && self.studio.replay.is_none() {
if !riding && self.studio_replay.is_none() {
self.previous_position = player.position;
}
let before = (
@@ -6728,10 +6728,10 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
// The player's own model (third person, the inventory's preview).
if self.studio.replay.is_none() {
if self.studio_replay.is_none() {
self.tick_player_model();
}
self.tick_emotes();
self.client_hooks_tick();
if !dead {
self.remote_tick_using();
}
@@ -9525,7 +9525,7 @@ impl Game<'_> {
KeyCode::KeyQ if !self.spectator() => {
// Minecraft.hasControlDown: the physical keys, so a toggled
// sprint on Left Control does not make Q drop the stack.
let all = self.has_control_down() || self.input_takes_stack_drop();
let all = self.has_control_down() || std::mem::take(&mut self.pad.drop_stack);
self.remote_drop(all);
}
KeyCode::KeyE
@@ -10528,7 +10528,7 @@ impl Game<'_> {
/// A connection's screen is over the world: connecting, reconfiguring, or
/// the level loading screen, a portal's included (`remote_overlay`).
pub(super) fn remote_screen_up(&self) -> bool {
self.studio.replay.is_none()
self.studio_replay.is_none()
&& self
.remote
.as_ref()
@@ -10612,7 +10612,7 @@ impl Game<'_> {
/// Connected or connecting with no player yet: `ConnectScreen`, then
/// `LevelLoadingScreen`, over the panorama with its blur.
pub(super) fn remote_loading(&self) -> bool {
if self.studio.replay.is_some() {
if self.studio_replay.is_some() {
return false;
}
use horizon_client::level_loading::Reason;
@@ -10714,17 +10714,11 @@ impl Game<'_> {
/// The profile name of another player the server sent, by entity id.
/// The UUID of another player's entity in the level.
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
let r = self.remote.as_ref()?;
let uuid = *r.level_players.get(&(id as i32))?;
(uuid != r.profile).then_some(uuid)
self.remote.as_ref()?.player_uuid(id)
}
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
let uuid = r.level_players.get(&(id as i32))?;
if *uuid == r.profile {
return None;
}
r.level_player_name(id as i32, uuid)
r.level_player_name(id as i32, &r.player_uuid(id)?)
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (their
@@ -11505,6 +11499,11 @@ impl Remote {
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// Another player's profile UUID, by their entity id (None for the local player).
pub(super) fn player_uuid(&self, id: u32) -> Option<u128> {
let uuid = *self.level_players.get(&(id as i32))?;
(uuid != self.profile).then_some(uuid)
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
@@ -292,8 +292,10 @@ fn pack_particles(
}
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
fn read_colors(root: &std::path::Path) -> horizon_client::pack_palette::PackColors {
let colors = horizon_client::pack_palette::read(root);
horizon_client::brewing::set_pack_colors(colors.effects.clone());
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
let colors = horizon_client::polytone_colors::PackColors::read(root);
horizon_client::pack_palette::set(&colors);
horizon_client::pack_palette::set_item_bars(root);
horizon_client::brewing::set_pack_colors(colors.effects());
colors
}
+7 -5
View File
@@ -823,7 +823,7 @@ impl Game<'_> {
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
self.cosmetics_frame();
self.client_hooks_frame();
}
/// `Hud.extractTabList`: the player list while the player-list key is
/// held. A local world shows it with a joined LAN guest or a `list`
@@ -900,10 +900,12 @@ impl Game<'_> {
let board = &self.score.board;
let objective = board.display_objective(sb::SLOT_LIST);
let up = |id: u128| upside_down.contains(&id);
// Horizon's mark before the names of Horizon players (the cosmetics service's cache).
let horizon = self.horizon_players(players.iter().map(|p| p.uuid));
let horizon_mark = super::cosmetics::MARK;
let marked = |id: u128| horizon.contains(&id).then_some(horizon_mark);
// A hook's mark before a name (Horizon's: they play Horizon).
debug_assert!(
self.serving.is_none(),
"a server thread calls no client hook"
);
let marked = horizon_hooks::engine::player_mark;
let inputs = TabInputs {
players: &players,
board,
+4 -11
View File
@@ -359,19 +359,12 @@ impl Game<'_> {
self.sculk.tracker = WardenSpawnTracker::from_nbt(tag);
}
}
/// The tracker as saved, once it has been loaded.
pub(super) fn warden_tracker_tag(&self) -> Option<horizon_world::nbt::Tag> {
self.sculk
.tracker_loaded
.then(|| self.sculk.tracker.to_nbt())
}
/// Write the tracker into the player data being saved. With a LAN owner,
/// `store_owner` rewrites that from the owner's guest, so it puts the
/// tracker there as well (`playerdata::with_warden`).
/// Write the tracker into the player data being saved.
pub(super) fn save_warden_tracker(&mut self) {
let Some(tag) = self.warden_tracker_tag() else {
if !self.sculk.tracker_loaded {
return;
};
}
let tag = self.sculk.tracker.to_nbt();
let extra = self
.level
.player_extra
+2
View File
@@ -261,6 +261,8 @@ impl<'a> Split<'a> {
.get(i.wrapping_sub(1))
.map(|g| g.options.clone()),
);
let seat = self.guests.get(i.wrapping_sub(1)).map_or(1, |g| g.seat);
horizon_hooks::engine::set_seat(seat - 1);
self.turn = i;
}
@@ -126,6 +126,7 @@ impl Split<'_> {
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
horizon_hooks::engine::set_seat(seat - 1);
let mut settings = self.host.settings.clone();
if pad.is_some() {
// The Controller setting is P1's own: a player who pressed a pad to join plays
@@ -135,6 +136,7 @@ impl Split<'_> {
}
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
horizon_hooks::engine::set_seat(0);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
@@ -299,16 +301,31 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
let made = if options.exists() {
None
} else {
std::fs::create_dir_all(&profile)?;
let mut new = self.host.settings.clone();
// The Controller setting is P1's, as on the profile page (`join`).
new.controller.enabled |= self.guests[i].pad.is_some();
// On P1's seat, so the crates' settings stores write P1's values too.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(0);
new.save(&options);
new
horizon_hooks::engine::set_seat(turn);
Some(new)
};
// The crates' stores load into the guest's seat, before its turn starts below.
let turn = horizon_hooks::seat();
horizon_hooks::engine::set_seat(self.guests[i].seat - 1);
let mut settings = match made {
Some(new) => {
horizon_client::settings::Settings::load_stores(&options);
new
}
None => horizon_client::settings::Settings::load(&options),
};
horizon_hooks::engine::set_seat(turn);
// A profile an earlier join saved with P1's off, or the player's pad would be dead.
settings.controller.enabled |= self.guests[i].pad.is_some();
// P1's own world through memory (`Ok`), else P1's server over the network (`Err`).
@@ -15,8 +15,6 @@ pub(super) fn rename_box() -> horizon_client::ui::EditBox {
edit.text_color = u32::MAX;
// AnvilScreen also sets `setTextColorUneditable(-1)`: white either way.
edit.text_color_uneditable = u32::MAX;
// AnvilScreen sets `setInvertHighlightedTextColor(false)`.
edit.invert_highlighted_text_color = false;
edit.focused = true;
edit.editable = false;
edit
@@ -42,7 +40,6 @@ mod tests {
// AnvilScreen.init: no input yet, so the box is read-only but focused.
let station_box = rename_box();
assert!(station_box.focused && !station_box.editable);
assert!(!station_box.invert_highlighted_text_color);
// slotChanged with an item: editable, and it shows the item's name.
let mut station = Station::new_kind(Kind::Anvil, [0; 3]);
+127 -162
View File
@@ -15,26 +15,6 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
/// Studio's state in a `Game`: the replay playing, its free camera, timeline and top bar. Studio is
/// a Horizon extra (docs/layers.md: the replay editor); the engine only asks it whether a replay
/// plays or the free camera flies.
#[derive(Default)]
pub(super) struct Studio {
/// The packet replay playing, if any.
pub(super) replay: Option<ReplayDriver>,
pub(super) freecam: horizon_client::studio::StudioFreecam,
pub(super) timeline: horizon_client::studio::TimelineUi,
pub(super) topbar: horizon_client::studio::TopBarUi,
pub(super) rmb_down: bool,
pub(super) camera_track_dirty: bool,
/// Camera perspective to restore after leaving replay playback.
pub(super) replay_previous_camera_type: Option<super::perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
pub(super) replay_standalone: bool,
pub(super) last_replay_tick: Option<std::time::Instant>,
pub(super) virtual_tick_ms: f32,
}
pub const HELP: &[&str] = &[
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
"studio record stop stop recording and finalize .mcpr archive",
@@ -129,13 +109,13 @@ impl Game<'_> {
match words[0] {
"freecam" => match words.get(1).copied() {
Some("on") => {
if self.studio.replay.is_none() {
if self.studio_replay.is_none() {
self.system_message(Text::translate(
"horizon.studio.freecam.replay_required",
&[],
));
} else if let Some((position, yaw, pitch)) = self.camera_view(1.0) {
self.studio.freecam.activate(
self.studio_freecam.activate(
position,
yaw,
pitch,
@@ -151,24 +131,24 @@ impl Game<'_> {
}
}
Some("off") => {
self.studio.freecam.deactivate();
self.studio_freecam.deactivate();
self.capture(false);
self.system_message(Text::translate("horizon.studio.freecam.disabled", &[]));
}
_ => self.system_message(Text::translate("horizon.studio.freecam.usage", &[])),
},
"keyframe" => {
let Some(driver) = self.studio.replay.as_ref() else {
let Some(driver) = self.studio_replay.as_ref() else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
return true;
};
let time_ms = driver.position_ms();
match words.get(1).copied() {
Some("add") => {
if !self.studio.freecam.active
if !self.studio_freecam.active
&& let Some((position, yaw, pitch)) = self.camera_view(1.0)
{
self.studio.freecam.activate(
self.studio_freecam.activate(
position,
yaw,
pitch,
@@ -176,16 +156,16 @@ impl Game<'_> {
);
}
self.capture(true);
self.studio.freecam.add_keyframe_at_current(time_ms);
self.studio.camera_track_dirty = true;
self.studio_freecam.add_keyframe_at_current(time_ms);
self.studio_camera_track_dirty = true;
self.system_message(Text::tr(
"horizon.studio.keyframe.added",
&[&time_ms.to_string()],
));
}
Some("clear") => {
self.studio.freecam.track.clear();
self.studio.camera_track_dirty = true;
self.studio_freecam.track.clear();
self.studio_camera_track_dirty = true;
self.system_message(Text::translate(
"horizon.studio.keyframe.cleared",
&[],
@@ -201,15 +181,15 @@ impl Game<'_> {
));
return true;
};
if index >= self.studio.freecam.track.keyframes.len() {
if index >= self.studio_freecam.track.keyframes.len() {
self.system_message(Text::translate(
"horizon.studio.keyframe.index_invalid",
&[],
));
return true;
}
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
self.system_message(Text::tr(
"horizon.studio.keyframe.removed",
&[&index.to_string()],
@@ -219,14 +199,14 @@ impl Game<'_> {
}
}
"path" => match words.get(1).copied() {
Some("lock") if !self.studio.freecam.track.is_empty() => {
self.studio.freecam.active = true;
self.studio.freecam.locked_to_path = true;
Some("lock") if !self.studio_freecam.track.is_empty() => {
self.studio_freecam.active = true;
self.studio_freecam.locked_to_path = true;
self.capture(true);
self.system_message(Text::translate("horizon.studio.path.locked", &[]));
}
Some("unlock") => {
self.studio.freecam.locked_to_path = false;
self.studio_freecam.locked_to_path = false;
self.system_message(Text::translate("horizon.studio.path.unlocked", &[]));
}
_ => self.system_message(Text::translate("horizon.studio.path.usage", &[])),
@@ -319,7 +299,7 @@ impl Game<'_> {
}
}
Some("pause") => {
if let Some(r) = self.studio.replay.as_mut() {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(true);
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
} else {
@@ -327,7 +307,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.studio.replay.as_mut() {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(false);
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
} else {
@@ -343,7 +323,7 @@ impl Game<'_> {
return true;
};
if let Ok(spd) = spd_str.parse::<f64>() {
let new_speed = if let Some(r) = self.studio.replay.as_mut() {
let new_speed = if let Some(r) = self.studio_replay.as_mut() {
r.set_speed(spd);
Some(r.speed())
} else {
@@ -377,12 +357,11 @@ impl Game<'_> {
return true;
};
if let Ok(ms) = ms_str.parse::<u32>() {
if self.studio.replay.is_some() {
if self.studio_replay.is_some() {
match self.seek_studio_replay(ms) {
Ok(()) => {
let actual_ms = self
.studio
.replay
.studio_replay
.as_ref()
.map_or(ms, |replay| ms.min(replay.duration_ms()));
let ms_text = actual_ms.to_string();
@@ -421,7 +400,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.studio.replay.as_ref() {
if let Some(r) = self.studio_replay.as_ref() {
let position = r.position_ms().to_string();
let duration = r.duration_ms().to_string();
let progress = format!("{:.1}", r.progress() * 100.0);
@@ -456,7 +435,7 @@ impl Game<'_> {
/// Reset playback tick accumulator and align positions on seek.
pub(super) fn reset_studio_playback_ticks(&mut self) {
self.studio.virtual_tick_ms = 0.0;
self.studio_virtual_tick_ms = 0.0;
if let Some(player) = &self.player {
self.previous_position = player.position;
if let Some(m) = self.player_model.as_mut() {
@@ -473,14 +452,12 @@ impl Game<'_> {
/// forward because its pre-replay world must remain intact.
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
let current_ms = self
.studio
.replay
.studio_replay
.as_ref()
.map_or(0, ReplayDriver::position_ms);
let packets = if self.studio.replay_standalone {
let packets = if self.studio_replay_standalone {
let packets = self
.studio
.replay
.studio_replay
.as_mut()
.expect("replay checked by caller")
.seek_and_collect(target_ms)?;
@@ -494,8 +471,7 @@ impl Game<'_> {
"backward seek requires a standalone replay",
));
}
self.studio
.replay
self.studio_replay
.as_mut()
.expect("replay checked by caller")
.seek_forward(target_ms)?
@@ -503,7 +479,7 @@ impl Game<'_> {
self.dispatch_studio_replay_packets(packets);
self.reset_studio_playback_ticks();
self.studio.last_replay_tick = Some(std::time::Instant::now());
self.last_replay_tick = Some(std::time::Instant::now());
Ok(())
}
@@ -534,19 +510,19 @@ impl Game<'_> {
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
let Some(driver) = self.studio.replay.as_mut() else {
self.studio.last_replay_tick = None;
let Some(driver) = self.studio_replay.as_mut() else {
self.last_replay_tick = None;
return Ok(());
};
let now = std::time::Instant::now();
let delta = match self.studio.last_replay_tick {
let delta = match self.last_replay_tick {
Some(last) => now
.saturating_duration_since(last)
.min(Duration::from_millis(250)),
None => Duration::from_millis(16),
};
self.studio.last_replay_tick = Some(now);
self.last_replay_tick = Some(now);
let ready_packets = match driver.advance(delta) {
Ok(pkts) => pkts,
@@ -557,18 +533,16 @@ impl Game<'_> {
};
self.dispatch_studio_replay_packets(ready_packets);
if self.studio.freecam.locked_to_path
if self.studio_freecam.locked_to_path
&& let Some(position) = self
.studio
.replay
.studio_replay
.as_ref()
.map(|replay| replay.position_ms())
{
self.studio.freecam.update_spline_playback(position);
self.studio_freecam.update_spline_playback(position);
}
let paused = self
.studio
.replay
.studio_replay
.as_ref()
.is_some_and(|replay| replay.is_paused());
if self.player_model.is_none() {
@@ -576,14 +550,13 @@ impl Game<'_> {
}
if !paused {
let speed = self
.studio
.replay
.studio_replay
.as_ref()
.map_or(1.0, |r| r.speed() as f32)
.max(0.1);
self.studio.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.studio.virtual_tick_ms >= 50.0 {
self.studio.virtual_tick_ms -= 50.0;
self.studio_virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.studio_virtual_tick_ms >= 50.0 {
self.studio_virtual_tick_ms -= 50.0;
if let Some(player) = &self.player {
let current_pos = player.position;
let (dx, dz) = (
@@ -634,7 +607,7 @@ impl Game<'_> {
m.previous_limb_speed = m.limb_speed;
}
if let Some((position, duration, paused, markers)) =
self.studio.replay.as_ref().map(|replay| {
self.studio_replay.as_ref().map(|replay| {
(
replay.position_ms(),
replay.duration_ms(),
@@ -643,10 +616,10 @@ impl Game<'_> {
)
})
{
self.studio.timeline.current_time_ms = u64::from(position);
self.studio.timeline.duration_ms = u64::from(duration);
self.studio.timeline.playing = !paused;
self.studio.timeline.markers = markers;
self.studio_timeline.current_time_ms = u64::from(position);
self.studio_timeline.duration_ms = u64::from(duration);
self.studio_timeline.playing = !paused;
self.studio_timeline.markers = markers;
}
Ok(())
}
@@ -666,10 +639,10 @@ impl Game<'_> {
self.capture(false);
}
TopBarAction::ExitStudio => {
if self.studio.replay.is_some() {
if self.studio_replay.is_some() {
self.close_studio_replay();
} else {
self.studio.freecam.deactivate();
self.studio_freecam.deactivate();
self.capture(false);
}
}
@@ -695,46 +668,42 @@ impl Game<'_> {
} else {
(glam::DVec3::ZERO, 0.0, 0.0)
};
self.studio
.freecam
self.studio_freecam
.activate(position, yaw, pitch, self.settings.fov);
}
TopBarAction::ToggleLockToPath => {
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
}
TopBarAction::ToggleFreecam => {
if self.studio.freecam.active {
self.studio.freecam.deactivate();
if self.studio_freecam.active {
self.studio_freecam.deactivate();
self.capture(false);
} else {
let (position, yaw, pitch) =
self.camera_view(0.0)
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
self.studio
.freecam
self.studio_freecam
.activate(position, yaw, pitch, self.settings.fov);
self.capture(true);
}
}
TopBarAction::AddKeyframe => {
self.studio
.freecam
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
self.studio.camera_track_dirty = true;
self.studio_freecam
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
self.studio_camera_track_dirty = true;
}
TopBarAction::AddMarker => self.add_studio_marker(),
TopBarAction::ClearKeyframes => {
self.studio.freecam.track.keyframes.clear();
self.studio.timeline.selected_keyframe = None;
self.studio.camera_track_dirty = true;
self.studio_freecam.track.keyframes.clear();
self.studio_timeline.selected_keyframe = None;
self.studio_camera_track_dirty = true;
}
TopBarAction::ToggleTimeline => {
self.studio.timeline.visible = !self.studio.timeline.visible;
self.studio_timeline.visible = !self.studio_timeline.visible;
}
TopBarAction::SplitClip => {
self.studio
.timeline
.split_at(self.studio.timeline.current_time_ms);
self.studio_timeline
.split_at(self.studio_timeline.current_time_ms);
}
}
}
@@ -747,97 +716,93 @@ impl Game<'_> {
use horizon_client::studio::TimelineAction;
match action {
TimelineAction::PauseForSeek => {
self.studio.timeline.playing = false;
if let Some(replay) = self.studio.replay.as_mut() {
self.studio_timeline.playing = false;
if let Some(replay) = self.studio_replay.as_mut() {
replay.set_paused(true);
}
}
TimelineAction::Seek(time_ms) => {
let time_ms = if self.studio.replay_standalone {
let time_ms = if self.studio_replay_standalone {
time_ms
} else {
let current =
self.studio
.replay
self.studio_replay
.as_ref()
.map_or(0, ReplayDriver::position_ms) as u64;
time_ms.max(current)
};
self.studio.timeline.current_time_ms = time_ms;
self.studio_timeline.current_time_ms = time_ms;
if let Err(error) = self.seek_studio_replay(time_ms.min(u32::MAX as u64) as u32) {
log::warn!("Studio timeline seek failed: {error}");
}
}
TimelineAction::TogglePlay => {
self.studio.timeline.playing = !self.studio.timeline.playing;
if let Some(replay) = self.studio.replay.as_mut() {
replay.set_paused(!self.studio.timeline.playing);
self.studio_timeline.playing = !self.studio_timeline.playing;
if let Some(replay) = self.studio_replay.as_mut() {
replay.set_paused(!self.studio_timeline.playing);
}
}
TimelineAction::StepBack => {
let time_ms = self.studio.timeline.current_time_ms.saturating_sub(1000);
let time_ms = self.studio_timeline.current_time_ms.saturating_sub(1000);
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
}
TimelineAction::StepForward => {
let time_ms = self
.studio
.timeline
.studio_timeline
.current_time_ms
.saturating_add(1000)
.min(self.studio.timeline.duration_ms);
.min(self.studio_timeline.duration_ms);
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
}
TimelineAction::SeekStart => {
self.handle_studio_timeline_action(TimelineAction::Seek(0))
}
TimelineAction::SeekEnd => self.handle_studio_timeline_action(TimelineAction::Seek(
self.studio.timeline.duration_ms,
self.studio_timeline.duration_ms,
)),
TimelineAction::SetSpeed(speed) => {
self.studio.timeline.speed = speed;
if let Some(replay) = self.studio.replay.as_mut() {
self.studio_timeline.speed = speed;
if let Some(replay) = self.studio_replay.as_mut() {
replay.set_speed(speed);
}
}
TimelineAction::ToggleFreecam => {
if self.studio.freecam.active {
self.studio.freecam.deactivate();
if self.studio_freecam.active {
self.studio_freecam.deactivate();
} else {
let (position, yaw, pitch) =
self.camera_view(0.0)
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
self.studio
.freecam
self.studio_freecam
.activate(position, yaw, pitch, self.settings.fov);
}
}
TimelineAction::AddCameraKeyframe => {
self.studio
.freecam
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
self.studio.camera_track_dirty = true;
self.studio_freecam
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
self.studio_camera_track_dirty = true;
}
TimelineAction::RemoveCameraKeyframe(index) => {
if index < self.studio.freecam.track.keyframes.len() {
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
if index < self.studio_freecam.track.keyframes.len() {
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
}
self.studio.timeline.selected_keyframe = None;
self.studio_timeline.selected_keyframe = None;
}
TimelineAction::SelectKeyframe(index) => {
self.studio.timeline.selected_keyframe = Some(index);
self.studio_timeline.selected_keyframe = Some(index);
}
TimelineAction::KeyframeMoved => {
self.studio.camera_track_dirty = true;
self.studio_camera_track_dirty = true;
}
TimelineAction::SplitClip(time_ms) => self.studio.timeline.split_at(time_ms),
TimelineAction::SplitClip(time_ms) => self.studio_timeline.split_at(time_ms),
TimelineAction::None => {}
}
}
/// Add a bookmark at the replay playhead and save it in the `.mcpr` archive.
pub(super) fn add_studio_marker(&mut self) {
let Some(replay) = self.studio.replay.as_mut() else {
let Some(replay) = self.studio_replay.as_mut() else {
return;
};
let label = Text::translate(
@@ -846,7 +811,7 @@ impl Game<'_> {
)
.plain();
match replay.add_marker(label) {
Ok(_) => self.studio.timeline.markers = replay.markers().to_vec(),
Ok(_) => self.studio_timeline.markers = replay.markers().to_vec(),
Err(error) => log::warn!("Studio: could not save replay marker: {error}"),
}
}
@@ -880,7 +845,7 @@ impl Game<'_> {
Ok(Some(res))
}
Some("pause") => {
if let Some(r) = self.studio.replay.as_mut() {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(true);
Ok(Some(json!({ "ok": true, "paused": true })))
} else {
@@ -888,7 +853,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.studio.replay.as_mut() {
if let Some(r) = self.studio_replay.as_mut() {
r.set_paused(false);
Ok(Some(json!({ "ok": true, "paused": false })))
} else {
@@ -898,7 +863,7 @@ impl Game<'_> {
Some("speed") => {
let spd_str = words.get(3).ok_or("missing speed value")?;
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
if let Some(r) = self.studio.replay.as_mut() {
if let Some(r) = self.studio_replay.as_mut() {
r.set_speed(spd);
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
} else {
@@ -908,7 +873,7 @@ impl Game<'_> {
Some("seek") => {
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
let pos = if let Some(r) = self.studio.replay.as_mut() {
let pos = if let Some(r) = self.studio_replay.as_mut() {
r.seek(ms).map_err(|e| format!("{e}"))?;
Some(r.position_ms())
} else {
@@ -922,7 +887,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.studio.replay.as_ref() {
if let Some(r) = self.studio_replay.as_ref() {
Ok(Some(json!({
"ok": true,
"active": true,
@@ -1034,13 +999,13 @@ impl Game<'_> {
}
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
if self.studio.camera_track_dirty
&& let Some(active) = self.studio.replay.as_mut()
if self.studio_camera_track_dirty
&& let Some(active) = self.studio_replay.as_mut()
{
if let Err(error) = active.save_camera_track(&self.studio.freecam.track) {
if let Err(error) = active.save_camera_track(&self.studio_freecam.track) {
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
self.studio.camera_track_dirty = false;
self.studio_camera_track_dirty = false;
}
// The recordings browser can be opened while the title screen is
// available but the slower world tables are still loading. Replay
@@ -1063,7 +1028,7 @@ impl Game<'_> {
}
};
let standalone = self.studio.replay.is_none() && self.remote.is_none();
let standalone = self.studio_replay.is_none() && self.remote.is_none();
if self.remote.is_none()
&& let Err(error) = self.init_remote_for_replay()
{
@@ -1073,17 +1038,17 @@ impl Game<'_> {
let duration_ms = driver.duration_ms();
let packets = driver.packet_count();
self.studio.timeline.duration_ms = u64::from(duration_ms);
self.studio.timeline.current_time_ms = 0;
self.studio.timeline.playing = !driver.is_paused();
self.studio.timeline.markers = driver.markers().to_vec();
self.studio.freecam.track = driver.camera_track().cloned().unwrap_or_default();
self.studio.camera_track_dirty = false;
if self.studio.replay.is_none() {
self.studio.replay_previous_camera_type = Some(self.camera_type);
self.studio.replay_standalone = standalone;
self.studio_timeline.duration_ms = u64::from(duration_ms);
self.studio_timeline.current_time_ms = 0;
self.studio_timeline.playing = !driver.is_paused();
self.studio_timeline.markers = driver.markers().to_vec();
self.studio_freecam.track = driver.camera_track().cloned().unwrap_or_default();
self.studio_camera_track_dirty = false;
if self.studio_replay.is_none() {
self.studio_replay_previous_camera_type = Some(self.camera_type);
self.studio_replay_standalone = standalone;
}
self.studio.replay = Some(driver);
self.studio_replay = Some(driver);
self.camera_type = super::perspective::CameraType::FirstPerson;
self.title = None;
self.screen = Screen::Playing;
@@ -1101,14 +1066,14 @@ impl Game<'_> {
}))
}
pub(super) fn close_studio_replay(&mut self) -> bool {
if self.studio.camera_track_dirty
&& let Some(driver) = self.studio.replay.as_mut()
&& let Err(error) = driver.save_camera_track(&self.studio.freecam.track)
if self.studio_camera_track_dirty
&& let Some(driver) = self.studio_replay.as_mut()
&& let Err(error) = driver.save_camera_track(&self.studio_freecam.track)
{
log::warn!("Studio: could not save camera path: {error}");
}
self.studio.camera_track_dirty = false;
let closed = self.studio.replay.take().is_some();
self.studio_camera_track_dirty = false;
let closed = self.studio_replay.take().is_some();
if !closed {
return false;
}
@@ -1116,23 +1081,23 @@ impl Game<'_> {
// A standalone replay owns a synthetic Remote connection. Treat that
// connection as replay state even if the saved origin flag was lost;
// sending it through leave_server() opens the Multiplayer screen.
let standalone = self.studio.replay_standalone
let standalone = self.studio_replay_standalone
|| self
.remote
.as_ref()
.is_some_and(|remote| remote.brand == "horizon-studio");
self.studio.replay_standalone = false;
if let Some(camera_type) = self.studio.replay_previous_camera_type.take() {
self.studio_replay_standalone = false;
if let Some(camera_type) = self.studio_replay_previous_camera_type.take() {
self.camera_type = camera_type;
}
self.studio.last_replay_tick = None;
self.studio.timeline.current_time_ms = 0;
self.studio.timeline.playing = false;
self.studio.timeline.markers.clear();
self.studio.timeline.split_points.clear();
self.studio.timeline.selected_keyframe = None;
self.studio.freecam.deactivate();
self.studio.virtual_tick_ms = 0.0;
self.last_replay_tick = None;
self.studio_timeline.current_time_ms = 0;
self.studio_timeline.playing = false;
self.studio_timeline.markers.clear();
self.studio_timeline.split_points.clear();
self.studio_timeline.selected_keyframe = None;
self.studio_freecam.deactivate();
self.studio_virtual_tick_ms = 0.0;
self.capture(false);
if standalone {
if let Some(mut remote) = self.remote.take() {
+1 -1
View File
@@ -149,7 +149,7 @@ impl Game<'_> {
self.audio
.play_unshared("minecraft:item.totem.use", Some(body.bounds.feet), 1.0, 1.0);
if target == TrackingTarget::LocalPlayer {
self.input_feedback(horizon_client::input_source::Feedback::Totem);
self.pad_rumble(horizon_client::gamepad::Rumble::Totem);
// findTotem, then LocalPlayer.displayItemActivation. The offsets
// come from the player's own random in vanilla, not the level's:
// the client-only particle random stands in for it. The server
+1 -1
View File
@@ -298,7 +298,7 @@ impl super::Game<'_> {
.into_iter()
.any(|action| self.keys.held(action))
|| self
.input_move()
.pad_move()
.is_some_and(|[left, forward]| left.abs() > 0.3 || forward.abs() > 0.3))
{
self.tutorial.input();
@@ -107,9 +107,9 @@ impl Game<'_> {
state: ElementState::Pressed,
..
}
) && !self.input_made_pointer_event()
) && !self.pad.menu.synthetic
{
self.input_keyboard_used();
self.pad.keyboard_took_over = true;
}
if let WindowEvent::MouseInput { state, button, .. } = &event {
if *state == ElementState::Pressed {
@@ -245,7 +245,7 @@ impl Game<'_> {
// Pause-menu and death-screen exits use the normal world-close action.
// A standalone replay owns a synthetic remote session, so it must go
// through replay cleanup instead of being treated like a server.
if self.studio.replay.is_some() {
if self.studio_replay.is_some() {
self.close_studio_replay();
return Ok(());
}
-43
View File
@@ -165,22 +165,6 @@ impl<T: Joined> JoinList<T> {
self.push(entity);
}
}
/// An entity that never left its level keeps its place in
/// `EntityTickList`: one a pass took out and puts back (an arrow bouncing
/// off) goes back before the first entity that joined after it. One that
/// has not joined yet is appended and numbered, as by `push`.
pub fn put_back(&mut self, entity: T) {
let seq = entity.join_seq();
let at = if seq.is_joined() {
self.list
.iter()
.position(|q| q.join_seq() > seq)
.unwrap_or(self.list.len())
} else {
self.list.len()
};
self.insert(at, entity);
}
}
impl<'a, T> IntoIterator for &'a JoinList<T> {
@@ -209,33 +193,6 @@ impl<T> IntoIterator for JoinList<T> {
mod tests {
use super::*;
#[derive(Debug)]
struct E {
join: JoinSeq,
}
crate::impl_joined!(E);
/// A numbered entity put back lands by its join number; a new one goes last.
#[test]
fn put_back_keeps_the_join_order() {
let mut list = JoinList::default();
for _ in 0..3 {
list.push(E {
join: JoinSeq::UNJOINED,
});
}
let first = list.remove(0);
let first_seq = first.join;
list.put_back(first);
assert_eq!(list[0].join, first_seq, "back before the later joins");
list.put_back(E {
join: JoinSeq::UNJOINED,
});
let seqs: Vec<_> = list.iter().map(|e| e.join).collect();
assert!(seqs.windows(2).all(|w| w[0] < w[1]), "{seqs:?}");
assert_eq!(seqs.len(), 4);
}
/// Whatever can change a list changes its stamp; only reading leaves it.
#[test]
fn a_stamp_holds_until_the_list_can_have_changed() {
@@ -53,27 +53,6 @@ pub fn exists(world: &Path, uuid: u128) -> bool {
path(world, uuid).is_file()
}
/// The key `ServerPlayer.addAdditionalSaveData` stores the `WardenSpawnTracker` under.
pub const WARDEN_TRACKER_KEY: &str = "warden_spawn_tracker";
/// Put a player's `warden_spawn_tracker` into the compound `store` writes from
/// (`guest.saved`), so a save keeps it rather than the older one loaded with
/// the file (the LAN owner's tracker lives in `Game`, `save_warden_tracker`).
/// Other saved keys are left as they are; no tracker (not loaded yet) changes
/// nothing.
pub fn with_warden(guest: &mut Guest, tracker: Option<Tag>) -> &mut Guest {
let Some(tracker) = tracker else {
return guest;
};
let saved = guest
.saved
.get_or_insert_with(|| Tag::Compound(Default::default()));
if let Tag::Compound(map) = saved {
map.insert(WARDEN_TRACKER_KEY.into(), tracker);
}
guest
}
/// `PlayerDataStorage.load`: a guest's saved state, if they have been here
/// before. The player's file, else its `.dat_old` (what a save cut off
/// between its two renames leaves, `write_dat`).
@@ -672,42 +651,6 @@ mod tests {
)
}
/// #9965: the owner's warden warning level and cooldown reach the file on
/// a save, instead of the ones the file was loaded with.
#[test]
fn a_stored_warden_tracker_replaces_the_loaded_one() {
let mut g = guest();
g.saved = Some(Tag::compound(vec![
(
"warden_spawn_tracker",
Tag::compound(vec![
("warning_level", Tag::Int(2)),
("cooldown_ticks", Tag::Int(77)),
("ticks_since_last_warning", Tag::Int(5)),
]),
),
("unrelated_key", Tag::Int(9)),
]));
let tracker = crate::sculk::WardenSpawnTracker {
ticks_since_last_warning: 0,
warning_level: 3,
cooldown_ticks: 200,
};
with_warden(&mut g, Some(tracker.to_nbt()));
let (tag, _) = round_trip(&g);
let mut back = guest();
let mut level = Level::default();
level.read_player(&tag);
level.player_extra = Some(tag);
apply(&mut back, &level);
let saved = back.saved.as_ref().unwrap();
let read =
crate::sculk::WardenSpawnTracker::from_nbt(saved.get("warden_spawn_tracker").unwrap());
assert_eq!((read.warning_level, read.cooldown_ticks), (3, 200));
assert_eq!(read.ticks_since_last_warning, 0);
assert!(matches!(saved.get("unrelated_key"), Some(Tag::Int(9))));
}
#[test]
fn a_new_player_faces_the_world_spawn_angle() {
let mut g = guest();
+3 -20
View File
@@ -771,26 +771,9 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
);
}
/// The player model's parts a [`BodyPose`] poses, in its order.
pub const BODY_PARTS: [&str; 6] = [
"head",
"body",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
];
/// A pose a higher layer lays over the player model's body after its vanilla animation (Horizon's
/// emotes, docs/cosmetics.md), handed to the model through [`Mob::emote`]. The engine names no
/// type of that layer (docs/layers.md); it downcasts its own back through [`std::any::Any`].
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
/// Poses the [`BODY_PARTS`] at `age` (the mob's ticks): `at_rest` holds each part before the
/// animation, `poses` as the animation left it; a part the model lacks holds the identity in
/// both and is not written back. False when it poses nothing at `age` (`poses` is then
/// ignored).
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
}
/// A pose laid over the player model's body after its vanilla animation, and the parts it poses:
/// the hook's own types (`horizon_hooks::players`), kept here under their old path.
pub use horizon_hooks::players::{BODY_PARTS, BodyPose};
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
pub fn setup(
+9 -111
View File
@@ -246,9 +246,7 @@ impl Goal for DefendVillage {
// wider, as the box test is strict).
let near = b.inflate(Vec3::new(1.0, 1.0, 1.0));
// Villager.getPlayerReputation of the player this golem sees: the
// villager's gossips about that player's own UUID. The villagers come
// through the same `attackTargeting` (`forCombat().range(64)`), so the
// golem must see them too.
// villager's gossips about that player's own UUID.
let bad = cx.w.others_near(near).any(|s| {
s.species == Species::Villager
&& s.alive
@@ -257,7 +255,6 @@ impl Goal for DefendVillage {
.is_some_and(|v| v.gossips.reputation(p.uuid) <= -100)
&& b.contains(s.position)
&& s.position.distance_squared(m.position) <= 64.0 * 64.0
&& m.sees(cx.w, Who::Mob(s.id))
});
bad && m.sees(cx.w, Who::Player)
}
@@ -325,39 +322,6 @@ impl Goal for MoveTowardsTarget {
m.nav_stop();
}
}
impl Mob {
/// `IronGolem.mobInteract`: the repair sound's pitch comes from the
/// golem's own random, not the level's.
pub fn golem_repair_pitch(&mut self) -> f32 {
1.0 + (self.random.next_float() - self.random.next_float()) * 0.2
}
}
/// `Level.isBrightOutside`: a fixed-time dimension (the End) is never bright.
fn bright_outside(w: &MobWorld) -> bool {
w.daylight && !w.dimension_type.has_fixed_time
}
/// `OfferFlowerGoal.getNearestEntity`: the nearest of
/// #candidate_for_iron_golem_gift (villagers and copper golems) in the golem's
/// box inflated by (6, 2, 6) that passes `forNonCombat().range(6.0)`: within
/// six blocks and in sight.
fn gift_candidate(m: &mut Mob, w: &MobWorld) -> Option<u32> {
let area = m.bounds().inflate(Vec3::new(6.0, 2.0, 6.0));
let mut best: Option<(f64, u32)> = None;
for s in w.others_near(area) {
if !matches!(s.species, Species::Villager | Species::CopperGolem)
|| !s.alive
|| !area.intersects(s.bounds)
{
continue;
}
let d = s.position.distance_squared(m.position);
if d > 36.0 || best.is_some_and(|(b, _)| d >= b) || !m.sees(w, Who::Mob(s.id)) {
continue;
}
best = Some((d, s.id));
}
best.map(|(_, id)| id)
}
/// `OfferFlowerGoal`: by day, now and then, hold out a poppy to a villager.
#[derive(Clone, Debug)]
struct OfferFlower {
@@ -370,10 +334,16 @@ impl Goal for OfferFlower {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
if !bright_outside(cx.w) || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
if !cx.w.daylight || m.random.next_int_bound(8000).unwrap_or(1) != 0 {
return false;
}
self.villager = gift_candidate(m, cx.w);
// The nearest of #candidate_for_iron_golem_gift: villagers and copper golems.
self.villager =
cx.w.nearby(m.position, 6.0, |s| {
matches!(s.species, Species::Villager | Species::CopperGolem) && s.alive
})
.map(|s| s.id)
.next();
self.villager.is_some()
}
fn can_continue(&mut self, _: &mut Mob, _: &mut Cx) -> bool {
@@ -642,78 +612,6 @@ mod tests {
Mob::new(id, Arc::new(kind), at, 0.0)
}
fn villager_at(id: u32, at: Vec3) -> crate::mobs::Seen {
mob(id, Species::Villager, at).seen(None)
}
fn pick(w: &MobWorld) -> Option<u32> {
let mut golem = mob(1, Species::IronGolem, Vec3::new(0.5, 64.0, 0.5));
gift_candidate(&mut golem, w)
}
fn with_chunks(world: &mut crate::world::World, _: &horizon_world::registry::BlockRegistry) {
for cx in -2..2 {
for cz in -2..2 {
world.chunks.insert(
(cx, cz),
std::sync::Arc::new(crate::chunk::Chunk::empty(cx, cz)),
);
}
}
}
/// `OfferFlowerGoal`: the search box is the golem's box inflated by
/// (6, 2, 6) and the candidate must be within six blocks (Euclidean).
#[test]
fn the_poppy_goes_to_a_villager_in_the_box_within_six_blocks() {
let cases = [
(Vec3::new(4.5, 64.0, 0.5), true),
// 6.5 blocks on one axis: outside the range.
(Vec3::new(7.0, 64.0, 0.5), false),
// About 8.5 blocks away, inside the per-axis box of six.
(Vec3::new(6.0, 64.0, 6.0), false),
// Five blocks above: outside the box's two-block rise.
(Vec3::new(0.5, 69.0, 0.5), false),
];
for (at, offered) in cases {
let villager = villager_at(2, at);
crate::mobs::world::with_built_test_world(&[villager], with_chunks, |w, _| {
assert_eq!(pick(w).is_some(), offered, "villager at {at:?}");
});
}
}
/// The candidate must also be in sight, and a fixed-time dimension is
/// never bright outside.
#[test]
fn the_poppy_needs_sight_and_a_dimension_with_a_day() {
let villager = villager_at(2, Vec3::new(4.5, 64.0, 0.5));
let wall = |world: &mut crate::world::World, r: &horizon_world::registry::BlockRegistry| {
with_chunks(world, r);
let stone = r.default_state("minecraft:stone").unwrap();
for y in 62..70 {
for z in -6..8 {
world.set(2, y, z, stone).unwrap();
}
}
};
crate::mobs::world::with_built_test_world(std::slice::from_ref(&villager), wall, |w, _| {
assert_eq!(pick(w), None, "a villager behind a wall");
});
crate::mobs::world::with_built_test_world(&[villager], with_chunks, |w, _| {
assert!(bright_outside(w));
assert_eq!(pick(w), Some(2));
let mut end = w.clone();
end.dimension_type =
horizon_world::dimension_type::DimensionFacts::vanilla("minecraft:the_end")
.unwrap();
assert!(!bright_outside(&end));
let mut night = w.clone();
night.daylight = false;
assert!(!bright_outside(&night));
});
}
#[test]
fn snow_trail_posts_block_place_with_golem_and_snow_state() {
let ran = crate::mobs::world::with_built_test_world(
+3 -48
View File
@@ -855,11 +855,9 @@ pub fn texture(m: &Mob) -> String {
"red",
"black",
];
// Shulker.getColor: the saved byte is signed; 16 and above are undyed, and
// DyeColor.byId sends a negative id to white (OutOfBoundsStrategy.ZERO).
match m.extra.color as i8 {
16.. => "entity/shulker/shulker".into(),
c => format!("entity/shulker/shulker_{}", COLORS[c.max(0) as usize]),
match COLORS.get(m.extra.color as usize) {
Some(c) => format!("entity/shulker/shulker_{c}"),
None => "entity/shulker/shulker".into(),
}
}
/// `attachFace.getOpposite().getRotation()` by attach face: the open
@@ -1146,46 +1144,3 @@ mod tests {
assert!((h - 1.0).abs() < 1e-9, "{h}");
}
}
#[cfg(test)]
mod saved_bytes_tests {
use super::*;
/// `Shulker.readAdditionalSaveData`: `AttachFace` is
/// `Direction.LEGACY_ID_CODEC` (`from3DDataValue` wraps the id through
/// `abs(id % 6)`), while `Peek` and `Color` are raw `getByteOr` bytes. The
/// load clamped all three to their setters' ranges instead.
#[test]
fn a_shulker_reads_its_face_and_lid_bytes_like_vanilla() {
use crate::entity_nbt::mob_from_nbt;
use horizon_world::nbt::Tag;
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = std::sync::Arc::make_mut(&mut kind);
k.id = "minecraft:shulker".into();
k.species = Species::Shulker;
let types = [("minecraft:shulker".into(), kind)].into();
let tag = Tag::compound([
("id", Tag::String("minecraft:shulker".into())),
(
"Pos",
Tag::List(vec![
Tag::Double(0.0f64.to_bits()),
Tag::Double(64.0f64.to_bits()),
Tag::Double(0.0f64.to_bits()),
]),
),
("AttachFace", Tag::Byte(7)),
("Peek", Tag::Byte(-1)),
("Color", Tag::Byte(-1)),
]);
let m = mob_from_nbt(&tag, &types, 1, None).unwrap();
assert_eq!(m.extra.variant3, 1, "abs(7 % 6) is up");
assert_eq!(m.extra.counter, -1, "the peek byte is kept");
assert_eq!(m.extra.color, 255, "the color byte is kept");
assert_eq!(
texture(&m),
"entity/shulker/shulker_white",
"DyeColor.byId(-1)"
);
}
}
+17 -15
View File
@@ -2148,16 +2148,18 @@ impl MotionWorld<'_, '_, '_> {
let gravity = living_motion::effective_gravity(self)?;
self.player.velocity = fall_flying_movement(v, self.player.pitch, self.player.yaw, gravity);
let movement = self.player.velocity;
let before = self.player.position;
self.move_self(movement)?;
// LivingEntity.handleFallFlyingCollisions: the fall-damage sound plays
// on both sides (a glider's own travel is client-side, and the sound
// must reach its own ears, #8906), only the server hurts, and it
// compares the horizontal speed before `updateFallFlyingMovement`
// with the delta `Entity.move` leaves (the collided axes zeroed).
// compares the delta before the move with the one after it, which
// `Entity.move` leaves as the movement it resolved. `move_entity` does
// not write that back, so the distance actually travelled stands in.
if self.player.collision.horizontal {
let v = self.player.velocity;
let after_h = (v.x * v.x + v.z * v.z).sqrt();
let dmg = fly_into_wall_damage(move_h, after_h);
let travelled = self.player.position.subtract(before);
let new_h = (travelled.x * travelled.x + travelled.z * travelled.z).sqrt();
let dmg = fly_into_wall_damage(move_h, new_h);
if dmg > 0.0 {
let first = self.player.events.len();
// getFallDamageSound((int) dmg)
@@ -3146,7 +3148,6 @@ mod tests {
assert_eq!(fly_into_wall_damage(0.3, 0.0), 0.0);
assert!((fly_into_wall_damage(0.5, 0.1) - 1.0).abs() < 1e-5);
assert!((fly_into_wall_damage(1.2, 0.0) - 9.0).abs() < 1e-5);
assert!(fly_into_wall_damage(0.01, 0.0) < 0.0);
}
use super::*;
@@ -4194,16 +4195,17 @@ mod tests {
health: &resources.health,
};
w.travel_fall_flying().unwrap();
let is_fall = |e: &_| {
matches!(
assert!(
w.player.events.iter().any(|e| matches!(
e,
MotionEvent::Fall(crate::landing::FallEvent::Sound { .. })
)
};
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
// The sound must play only once on impact, not repeat on subsequent ticks (#12304):
w.travel_fall_flying().unwrap();
assert_eq!(w.player.events.iter().filter(|e| is_fall(e)).count(), 1);
MotionEvent::Fall(crate::landing::FallEvent::Sound {
sound: crate::landing::SceneSound::SmallFall,
..
})
)),
"{:?}",
w.player.events
);
assert!(
!w.player
.hurt_log
+17
View File
@@ -149,8 +149,20 @@ pub struct Session {
far: Arc<crate::lod_net::Inbox>,
/// The recording running, if any.
recorder: RecorderSlot,
/// This connection's number, new for each one ([`Session::serial`]).
serial: u64,
}
/// The next [`Session::serial`].
static NEXT_SERIAL: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn next_serial() -> u64 {
NEXT_SERIAL.fetch_add(1, Ordering::Relaxed)
}
impl Session {
/// A number of its own for each connection, never 0: entity ids are per connection, so what
/// is kept by id goes when it changes (`horizon_hooks::players::PlayerModels::connection`).
pub fn serial(&self) -> u64 {
self.serial
}
/// The negotiated wire release, once the connection has been established.
pub fn server_version(&self) -> horizon_protocol::adapter::ServerVersion {
self.writer.lock().unwrap().as_ref().map_or(
@@ -256,6 +268,7 @@ impl Session {
debug: false,
far: Arc::new(crate::lod_net::Inbox::default()),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
}
}
fn start(
@@ -333,6 +346,7 @@ impl Session {
debug,
far,
recorder,
serial: next_serial(),
}
}
/// Everything received since the last poll.
@@ -1829,6 +1843,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let started = std::time::Instant::now();
if explicit_disconnect {
@@ -1932,6 +1947,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let (thread_writer, thread_configuring, thread_recorder) = (
session.writer.clone(),
@@ -2215,6 +2231,7 @@ mod tests {
debug: false,
far: Arc::default(),
recorder: Arc::new(Mutex::new(None)),
serial: next_serial(),
};
let recorder = TestRecorder::default();
session.start_recording(recorder.clone(), |_| {});
+5 -36
View File
@@ -852,24 +852,14 @@ fn parse_recipe_sources<'a>(
"minecraft:stonecutting" => {
// A result count outside 1..=99 fails vanilla's codec: no recipe.
if let Some(count) = crate::crafting::template_count(&v["result"]) {
let result = if v["result"].get("components").is_some() {
match SmithingResult::parse(&v["result"], defaults)
.and_then(|result| result.create())
{
Some(result) => result,
None => continue,
}
} else {
ItemStack::new(
horizon_world::loot::recipe_result_id(&v["result"])
.unwrap_or("minecraft:air"),
count,
)
};
stonecutting.push(Stonecutting {
id,
input: ingredient(&v["ingredient"], items),
result,
result: ItemStack::new(
horizon_world::loot::recipe_result_id(&v["result"])
.unwrap_or("minecraft:air"),
count,
),
});
}
}
@@ -2226,27 +2216,6 @@ mod tests {
vec!["minecraft:diamond_sword", "minecraft:diamond_axe"]
);
}
#[test]
fn stonecutter_result_keeps_its_components() {
let root = std::env::temp_dir().join(format!(
"horizon-stonecutter-components-{}",
std::process::id()
));
let dir = root.join("data/minecraft/recipe");
fs::create_dir_all(&dir).unwrap();
fs::write(root.join("items.json"), r#"{"minecraft:stone":{}}"#).unwrap();
fs::write(
dir.join("cut.json"),
r#"{"type":"minecraft:stonecutting","ingredient":"minecraft:stone","result":{"id":"minecraft:stone","components":{"minecraft:custom_name":"Cut Stone"}}}"#,
)
.unwrap();
let recipes = StationRecipes::load(&root, &sample_items(), &PackOverlay::NONE);
let _ = fs::remove_dir_all(&root);
let listed = recipes.stonecutter(&ItemStack::new("minecraft:stone", 1));
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].result.custom_name().as_deref(), Some("Cut Stone"));
}
/// A world pack's stonecutting recipe takes its place in registration
/// order (path, then namespace), not a place after the game's own.
#[test]
+9 -3
View File
@@ -70,7 +70,8 @@ struct Shared {
/// The game's handle. Dropping it stops the thread.
pub struct Cosmetics {
shared: Arc<Shared>,
worker: Option<Box<dyn FnMut() + Send>>,
/// Behind a lock so one client can be shared (`Arc<Cosmetics>` is `Send + Sync`).
worker: Mutex<Option<Box<dyn FnMut() + Send>>>,
}
impl Cosmetics {
@@ -78,7 +79,12 @@ impl Cosmetics {
/// `cache` when given.
pub fn new(transport: impl Transport, cache: Option<PathBuf>) -> Self {
let mut c = Self::manual(transport, cache, Arc::new(Instant::now));
let mut work = c.worker.take().expect("worker");
let mut work = c
.worker
.get_mut()
.unwrap_or_else(PoisonError::into_inner)
.take()
.expect("worker");
let shared = c.shared.clone();
let _ = std::thread::Builder::new()
.name("Cosmetics".into())
@@ -125,7 +131,7 @@ impl Cosmetics {
});
Self {
shared,
worker: Some(worker),
worker: Mutex::new(Some(worker)),
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ fn clock() -> (Clock, Arc<AtomicU64>) {
}
fn run(c: &mut Cosmetics) {
(c.worker.as_mut().unwrap())();
(c.worker.get_mut().unwrap().as_mut().unwrap())();
}
fn ids(worn: Option<Worn>) -> Vec<String> {
-9
View File
@@ -47,15 +47,6 @@ pub fn open_vulkan_device(
Some(unsafe { adapter.create_device_from_hal(open, desc) })
}
/// wgpu's opt-in to its `EXPERIMENTAL_*` features, which the crates that forbid unsafe code
/// can't give: hardware ray queries, asked for only when the player turns ray tracing on
/// (`gfx/ray_query.rs` in horizon-client-render).
pub fn experimental_features() -> wgpu::ExperimentalFeatures {
// Safety: wgpu warns that its experimental features may have bugs that reach undefined
// behaviour through safe calls. Horizon accepts that only on request, for ray queries.
unsafe { wgpu::ExperimentalFeatures::enabled() }
}
/// Tells the compositor, through `VK_EXT_hdr_metadata`, that the swapchain's
/// content is BT.709, peaks at `peak` nits and is no darker than `black`. Needs a
/// device from `open_vulkan_device` and a configured surface; does nothing otherwise.
+311 -1
View File
@@ -19,11 +19,13 @@
pub mod hud;
pub mod pages;
pub mod players;
mod registry;
pub mod settings;
pub mod tunnel;
use registry::Registry;
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
/// What a crate above the engine hears from the game. Every method has an empty default:
@@ -42,6 +44,43 @@ pub trait Hooks: Sync {
/// The game is ending normally: its window closed and the world is saved. Not after a crash.
fn shutdown(&self) {}
/// Once a frame, before drawing: `sprites` takes the pictures the crate's
/// [`players::Sprites`] lacked. Only a window's game calls it (each splitscreen player in
/// turn, [`seat`]), never the integrated server's thread or a dedicated server; so are the
/// player methods below (not [`Hooks::tunnel`]).
fn frame(&self, _frame: &players::Frame, _sprites: &mut dyn players::SpriteStore) {}
/// Once a client tick while a world is shown: the player models in view.
fn tick(&self, _models: &mut dyn players::PlayerModels) {}
/// What goes by a player model's name over its head, each frame its name tag is drawn. The
/// first hook that answers is used.
fn name_decoration(
&self,
_model: &players::PlayerModel,
_sprites: &dyn players::Sprites,
) -> Option<players::NameDecoration> {
None
}
/// The mark before the name of the player `uuid` in the player list, each frame it shows.
/// The first hook that answers is used.
fn player_mark(&self, _uuid: u128) -> Option<players::Mark> {
None
}
/// How the camera stands in third person, each frame. The first hook that answers is used.
fn camera(&self, _own: &players::CameraView) -> Option<players::CameraOverride> {
None
}
/// Draws on a player model, after its vanilla layers, each time it is drawn.
fn player_layers(&self, _model: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
/// Draws around the window's own model while its body is out of view (first person), once a
/// frame after the first-person model: [`players::Pass::FirstPerson`].
fn first_person(&self, _own: &players::PlayerModel, _draw: &mut dyn players::PlayerDraw) {}
/// The resolver of `friend:` addresses this crate offers ([`tunnel`]); the first hook that
/// offers one is used. Asked on the network thread when joining and on the game thread when
/// an address is shown, never per frame.
@@ -89,10 +128,27 @@ fn all() -> &'static [&'static dyn Hooks] {
HOOKS.all()
}
thread_local! {
static SEAT: Cell<usize> = const { Cell::new(0) };
}
/// The splitscreen player whose turn it is: 0 for P1 (and without splitscreen), 1 for P2...
/// Splitscreen players are one process, each `Game` in turn on the game's thread, so a crate's
/// statics are shared by them: a crate that keeps per-player state (settings, pages, HUD) indexes
/// it by this. A player keeps its seat while it plays. Other threads always see 0.
pub fn seat() -> usize {
SEAT.get()
}
/// The engine's side: where it calls the hooks. Only the engine calls these.
pub mod engine {
use super::*;
/// The player whose turn starts on this thread ([`seat`]).
pub fn set_seat(seat: usize) {
SEAT.set(seat);
}
/// Seals every list (hooks and the registries); their first use does it anyway. Call where
/// registration ends.
pub fn seal() {
@@ -130,6 +186,57 @@ pub mod engine {
}
}
/// Whether any hook is registered: without one the engine builds no view to hand them.
pub fn any() -> bool {
!all().is_empty()
}
/// [`Hooks::frame`].
pub fn frame(frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
for hooks in all() {
hooks.frame(frame, sprites);
}
}
/// [`Hooks::name_decoration`].
pub fn name_decoration(
model: &players::PlayerModel,
sprites: &dyn players::Sprites,
) -> Option<players::NameDecoration> {
all().iter().find_map(|h| h.name_decoration(model, sprites))
}
/// [`Hooks::player_mark`].
pub fn player_mark(uuid: u128) -> Option<players::Mark> {
all().iter().find_map(|h| h.player_mark(uuid))
}
/// [`Hooks::player_layers`].
pub fn player_layers(model: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
for hooks in all() {
hooks.player_layers(model, draw);
}
}
/// [`Hooks::first_person`].
pub fn first_person(own: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
for hooks in all() {
hooks.first_person(own, draw);
}
}
/// [`Hooks::camera`].
pub fn camera(own: &players::CameraView) -> Option<players::CameraOverride> {
all().iter().find_map(|h| h.camera(own))
}
/// [`Hooks::tick`].
pub fn tick(models: &mut dyn players::PlayerModels) {
for hooks in all() {
hooks.tick(models);
}
}
/// [`Hooks::tunnel`]: the first registered hook's resolver.
pub fn tunnel() -> Option<&'static dyn tunnel::Resolver> {
all().iter().find_map(|hooks| hooks.tunnel())
@@ -139,6 +246,7 @@ pub mod engine {
#[cfg(test)]
mod tests {
use super::*;
use players::PlayerModels as _;
use std::sync::Mutex as StdMutex;
/// Records what it heard. One process-wide list, as the game has: this test is the only
@@ -155,6 +263,169 @@ mod tests {
fn published(&self, open: bool) {
self.0.lock().unwrap().push(format!("published {open}"));
}
fn frame(&self, frame: &players::Frame, sprites: &mut dyn players::SpriteStore) {
self.0
.lock()
.unwrap()
.push(format!("frame {}", frame.connection));
if !sprites.has(1) {
sprites.put(1, vec![0; 64 * 64 * 4]);
}
}
fn name_decoration(
&self,
model: &players::PlayerModel,
sprites: &dyn players::Sprites,
) -> Option<players::NameDecoration> {
let badge = sprites.get(1).map(|uv| players::Badge::new(uv, 0xFF0000));
(model.kind == players::Kind::Player)
.then(|| players::NameDecoration::new(Some(MARK), badge, Some(2.0)))
}
fn player_mark(&self, uuid: u128) -> Option<players::Mark> {
(uuid == 9).then_some(MARK)
}
fn camera(&self, own: &players::CameraView) -> Option<players::CameraOverride> {
Some(players::CameraOverride::new(own.age / 10.0))
}
/// A hat on the head, its brim turned by the model's age.
fn player_layers(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
let (Some(head), Some(uv)) = (draw.bone(players::Bone::Head), draw.sprite(1)) else {
return;
};
let turn = draw.ticks() / 100.0;
let look = players::Look::new(uv);
draw.draw(&HAT, head, look, &mut |p| p.pose("brim")[4] = turn);
}
/// A spark at the eyes, as bright as the head turns.
fn first_person(&self, _: &players::PlayerModel, draw: &mut dyn players::PlayerDraw) {
let alpha = draw.motion().head_yaw / 90.0;
let spark = players::Particle::new([[0.0; 3]; 4], [[0.0; 2]; 4], alpha);
draw.particles([0.0; 4], &[spark], players::Light::Full);
}
fn tick(&self, models: &mut dyn players::PlayerModels) {
let mut ids = Vec::new();
models.for_each(&mut |m| ids.push(m.id));
for id in ids {
models.set_body_pose(id, Some(std::sync::Arc::new(Still)));
}
}
}
#[derive(Debug)]
struct Still;
impl players::BodyPose for Still {
fn pose(&self, _: f32, _: &[[f32; 9]; 6], _: &mut [[f32; 9]; 6]) -> bool {
false
}
}
/// Two models, poses by id.
struct Two(std::collections::BTreeMap<u32, std::sync::Arc<dyn players::BodyPose>>);
impl players::PlayerModels for Two {
fn connection(&self) -> u64 {
1
}
fn for_each(&self, f: &mut dyn FnMut(players::PlayerModel)) {
for id in [3, 7] {
f(players::PlayerModel::new(
id,
players::Kind::Player,
None,
0,
false,
));
}
}
fn body_pose(&self, id: u32) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
self.0.get(&id)
}
fn set_body_pose(&mut self, id: u32, pose: Option<std::sync::Arc<dyn players::BodyPose>>) {
match pose {
Some(p) => self.0.insert(id, p),
None => self.0.remove(&id),
};
}
}
const MARK: players::Mark = &[(0, 0, 8, 8, 0xFF00_00FF)];
/// One sprite slot, as the game's atlas has many.
#[derive(Default)]
struct Atlas(Option<(u64, usize)>);
impl players::SpriteStore for Atlas {
fn has(&self, id: u64) -> bool {
self.0.is_some_and(|(i, _)| i == id)
}
fn put(&mut self, id: u64, rgba: Vec<u8>) {
self.0 = Some((id, rgba.len()));
}
}
impl players::Sprites for Atlas {
fn get(&self, id: u64) -> Option<[f32; 4]> {
players::SpriteStore::has(self, id).then_some([0.0, 0.0, 0.5, 0.5])
}
}
static HAT: std::sync::LazyLock<std::sync::Arc<players::Mesh>> =
std::sync::LazyLock::new(|| {
let brim = players::Mesh::new("brim".into(), [0.0; 9], Vec::new(), Vec::new());
std::sync::Arc::new(players::Mesh::new(
"hat".into(),
[0.0; 9],
Vec::new(),
vec![brim],
))
});
/// A player drawn at age 50, its head where the camera is, turned 45 degrees; logs each draw
/// and its particles' alphas.
#[derive(Default)]
struct Canvas(Vec<(String, [[f32; 4]; 4], [f32; 4], f32)>, Vec<f32>);
impl players::PlayerDraw for Canvas {
fn pass(&self) -> players::Pass {
players::Pass::World
}
fn ticks(&self) -> f32 {
50.0
}
fn base(&self) -> [[f32; 4]; 4] {
[[1.0, 0.0, 0.0, 0.0]; 4]
}
fn motion(&self) -> players::Motion {
players::Motion::new([0.0; 3], 45.0, 0.0)
}
fn view(&self) -> players::View {
players::View::default()
}
fn bone(&mut self, bone: players::Bone) -> Option<[[f32; 4]; 4]> {
(bone == players::Bone::Head).then_some([[1.0, 0.0, 0.0, 0.0]; 4])
}
fn body_pose(&self) -> Option<&std::sync::Arc<dyn players::BodyPose>> {
None
}
fn sprite(&self, id: u64) -> Option<[f32; 4]> {
(id == 1).then_some([0.0, 0.0, 0.5, 0.5])
}
fn draw(
&mut self,
mesh: &std::sync::Arc<players::Mesh>,
matrix: [[f32; 4]; 4],
look: players::Look,
pose: &mut dyn FnMut(&mut dyn players::PartPoses),
) {
struct Rest([f32; 9]);
impl players::PartPoses for Rest {
fn pose(&mut self, _: &str) -> &mut [f32; 9] {
&mut self.0
}
}
let mut brim = Rest(mesh.children[0].pose);
pose(&mut brim);
self.0.push((mesh.name.clone(), matrix, look.uv, brim.0[4]));
}
fn particles(&mut self, _: [f32; 4], particles: &[players::Particle], _: players::Light) {
self.1.extend(particles.iter().map(|p| p.alpha));
}
}
/// Implements nothing: the defaults do nothing.
@@ -173,9 +444,48 @@ mod tests {
engine::published(open);
}
engine::shutdown();
assert!(engine::any());
let mut atlas = Atlas::default();
let model = |kind| players::PlayerModel::new(5, kind, Some(9), 0, false);
// Before the frame put the sprite in: the decoration without its badge.
let before = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
assert!(before.badge.is_none() && before.top == Some(2.0));
engine::frame(&players::Frame::new(4, false), &mut atlas);
assert_eq!(atlas.0, Some((1, 64 * 64 * 4)));
let after = engine::name_decoration(&model(players::Kind::Player), &atlas).unwrap();
assert_eq!(
after.badge.map(|b| (b.uv, b.line)),
Some(([0.0, 0.0, 0.5, 0.5], 0xFF0000))
);
assert!(engine::name_decoration(&model(players::Kind::Mannequin), &atlas).is_none());
assert_eq!(engine::player_mark(9), Some(MARK));
assert_eq!(engine::player_mark(8), None);
let camera = engine::camera(&players::CameraView::new(5.0));
assert_eq!(camera.map(|c| c.distance), Some(0.5));
let mut canvas = Canvas::default();
engine::player_layers(&model(players::Kind::Player), &mut canvas);
assert_eq!(
canvas.0,
[(
"hat".to_owned(),
[[1.0, 0.0, 0.0, 0.0]; 4],
[0.0, 0.0, 0.5, 0.5],
0.5
)]
);
engine::first_person(&model(players::Kind::Own), &mut canvas);
assert_eq!(canvas.1, [0.5]);
let mut models = Two(Default::default());
engine::tick(&mut models);
assert!(models.body_pose(3).is_some() && models.body_pose(7).is_some());
assert_eq!(
*RECORDER.0.lock().unwrap(),
["started 0.2.31", "published true", "published false"]
[
"started 0.2.31",
"published true",
"published false",
"frame 4"
]
);
assert_eq!(register(&Silent), Err(TooLate));
}
+456
View File
@@ -0,0 +1,456 @@
//! The players the game shows, as a crate above the engine sees them (Horizon's emotes, spec
//! #14091): [`PlayerModels`] in [`crate::Hooks::tick`], [`Frame`] in [`crate::Hooks::frame`],
//! [`BodyPose`], a pose laid over a player's body after its vanilla animation, what goes by their
//! names, and the crate's [`Sprites`]. Plain types only: no engine type crosses this line.
use std::sync::Arc;
/// The player model's parts a [`BodyPose`] poses, in its order.
pub const BODY_PARTS: [&str; 6] = [
"head",
"body",
"right_arm",
"left_arm",
"right_leg",
"left_leg",
];
/// A pose a crate lays over a player model's body after its vanilla animation (Horizon's emotes,
/// docs/cosmetics.md), handed to the model with [`PlayerModels::set_body_pose`]. The crate
/// downcasts its own back through [`std::any::Any`].
pub trait BodyPose: std::any::Any + std::fmt::Debug + Send + Sync {
/// Poses the [`BODY_PARTS`] at `age` (the model's ticks): `at_rest` holds each part before the
/// animation, `poses` as the animation left it (x, y, z, x/y/z rotation, x/y/z scale); a part
/// the model lacks holds the identity in both and is not written back. False when it poses
/// nothing at `age` (`poses` is then ignored).
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool;
}
/// What a player model is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Kind {
/// The player of this window, seen from outside (third person, the inventory picture).
Own,
/// Another player.
Player,
/// A mannequin: a player model with no player.
Mannequin,
}
/// One player model, as [`PlayerModels::for_each`] shows it.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct PlayerModel {
/// Its entity id on this connection ([`PlayerModels::connection`]).
pub id: u32,
pub kind: Kind,
/// The player's UUID as the server gave it (the window's own: its profile); None for
/// mannequins.
pub uuid: Option<u128>,
/// Its age in ticks, the clock a [`BodyPose`] is timed on.
pub age: u32,
/// It moved sideways since the last tick (more than 0.01 blocks).
pub moved: bool,
/// What it wears that covers a crate's drawing.
pub gear: Gear,
/// It is drawn invisible (the effect, or a trader's disguise).
pub invisible: bool,
}
impl PlayerModel {
pub fn new(id: u32, kind: Kind, uuid: Option<u128>, age: u32, moved: bool) -> Self {
Self {
id,
kind,
uuid,
age,
moved,
gear: Gear::default(),
invisible: false,
}
}
/// With what it wears and whether it is invisible.
pub fn looks(self, gear: Gear, invisible: bool) -> Self {
Self {
gear,
invisible,
..self
}
}
}
/// What a player model wears where a crate may draw.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct Gear {
/// A helmet or a head item.
pub head: bool,
/// A chestplate (an elytra is on the back).
pub chest: bool,
/// A cape or an elytra.
pub back: bool,
}
impl Gear {
pub fn new(head: bool, chest: bool, back: bool) -> Self {
Self { head, chest, back }
}
}
/// The player models of the world this window shows, once a tick ([`crate::Hooks::tick`]).
pub trait PlayerModels {
/// Changes on every join: entity ids are per connection, so what a crate keeps by id goes
/// when this changes.
fn connection(&self) -> u64;
/// Each player model in view (other players and mannequins; the window's own model first).
fn for_each(&self, f: &mut dyn FnMut(PlayerModel));
/// The pose laid over model `id`, if any.
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>>;
/// Lays `pose` over model `id` (None: its vanilla animation alone). Unknown ids are ignored.
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>);
}
/// What [`crate::Hooks::frame`] gets, once a frame before drawing.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Frame {
/// As [`PlayerModels::connection`]; 0 with no world.
pub connection: u64,
/// The world is the window's own single-player one: its server names the player by an
/// offline profile.
pub solo: bool,
}
impl Frame {
pub fn new(connection: u64, solo: bool) -> Self {
Self { connection, solo }
}
}
/// Rectangles drawn before a name: x, y, width, height in text pixels from the name line's top
/// left corner (a pixel left of its background), and `0xAARRGGBB`.
pub type Mark = &'static [(u8, u8, u8, u8, u32)];
/// What a crate draws by a player's name over their head ([`crate::Hooks::name_decoration`]).
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct NameDecoration {
/// Before the name.
pub mark: Option<Mark>,
/// After the name, and a line under it.
pub badge: Option<Badge>,
/// How high the crate's drawing on the body gets above the neck, in model pixels: the tag
/// goes up over it.
pub top: Option<f32>,
}
impl NameDecoration {
pub fn new(mark: Option<Mark>, badge: Option<Badge>, top: Option<f32>) -> Self {
Self { mark, badge, top }
}
}
/// A badge after a name: 8 text pixels square, a pixel right of the background.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Badge {
/// Its picture: a part of a [`Sprites`] sprite, in atlas coordinates (u0, v0, u1, v1).
pub uv: [f32; 4],
/// The line under the name, `0xRRGGBB`.
pub line: u32,
}
impl Badge {
pub fn new(uv: [f32; 4], line: u32) -> Self {
Self { uv, line }
}
}
/// A crate's pictures in the game's atlas, by ids of its own: 64×64 RGBA each, in the slots skins
/// use (one no frame drew for a while may go). Read while drawing.
pub trait Sprites {
/// Where sprite `id` is (u0, v0, u1, v1), and it is kept this frame. None until
/// [`SpriteStore::put`] put it there.
fn get(&self, id: u64) -> Option<[f32; 4]>;
}
/// Where a crate puts its [`Sprites`], in [`crate::Hooks::frame`].
pub trait SpriteStore {
/// Sprite `id` is in the atlas.
fn has(&self, id: u64) -> bool;
/// Puts sprite `id` in the atlas (64×64 RGBA), if a slot is free.
fn put(&mut self, id: u64, rgba: Vec<u8>);
}
/// The window's own model, as [`crate::Hooks::camera`] sees it.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct CameraView {
/// Its age in ticks, with the frame's part of a tick.
pub age: f32,
}
impl CameraView {
pub fn new(age: f32) -> Self {
Self { age }
}
}
/// How a crate moves the camera in third person.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct CameraOverride {
/// How far it stands back, as a share of the usual distance.
pub distance: f32,
}
impl CameraOverride {
pub fn new(distance: f32) -> Self {
Self { distance }
}
}
/// A model a crate draws on players, as the game's own models are: a tree of parts, 16 units a
/// block, y down. Build it once and keep the `Arc`: the engine prepares it for the GPU once, by
/// the `Arc`'s address, while the crate holds it.
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct Mesh {
/// What [`PartPoses::pose`] names it by.
pub name: String,
/// At rest: x, y, z, x/y/z rotation (radians), x/y/z scale.
pub pose: [f32; 9],
pub quads: Vec<Quad>,
pub children: Vec<Mesh>,
}
impl Mesh {
pub fn new(name: String, pose: [f32; 9], quads: Vec<Quad>, children: Vec<Mesh>) -> Self {
Self {
name,
pose,
quads,
children,
}
}
}
/// One face of a [`Mesh`].
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Quad {
pub normal: [f32; 3],
/// Its corners: x, y, z, then u, v across the sprite (0 to 1).
pub vertices: [[f32; 5]; 4],
}
impl Quad {
pub fn new(normal: [f32; 3], vertices: [[f32; 5]; 4]) -> Self {
Self { normal, vertices }
}
}
/// A part of the player's body a crate draws on.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Bone {
Head,
Body,
RightArm,
LeftArm,
RightLeg,
LeftLeg,
}
impl Bone {
/// Its name among [`BODY_PARTS`].
pub fn part(self) -> &'static str {
BODY_PARTS[self as usize]
}
}
/// Which picture of a player is drawn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Pass {
/// In the world, seen by the camera (the window's own model too, in third person).
World,
/// The inventory's picture of the window's own model.
Picture,
/// The window's own model while its body is out of view (first person): no bone, only what
/// floats around it ([`crate::Hooks::first_person`]).
FirstPerson,
}
/// How a player model moves this frame ([`PlayerDraw::motion`]), with the frame's part of a tick.
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct Motion {
/// Where its feet are, in the world.
pub feet: [f64; 3],
/// Where its head turns (degrees, the game's yaw and pitch).
pub head_yaw: f32,
pub head_pitch: f32,
pub crouching: bool,
/// Lies in a bed.
pub asleep: bool,
/// Flies with an elytra.
pub flying: bool,
/// Swims (any part of the swimming pose).
pub swimming: bool,
}
impl Motion {
pub fn new(feet: [f64; 3], head_yaw: f32, head_pitch: f32) -> Self {
Self {
feet,
head_yaw,
head_pitch,
..Self::default()
}
}
/// With its poses: crouching, asleep, flying, swimming.
pub fn poses(self, crouching: bool, asleep: bool, flying: bool, swimming: bool) -> Self {
Self {
crouching,
asleep,
flying,
swimming,
..self
}
}
}
/// The camera a player model is drawn for ([`PlayerDraw::view`]).
#[derive(Clone, Copy, Debug, Default)]
#[non_exhaustive]
pub struct View {
/// Where it is, in the world: matrices and [`Particle`] corners are relative to it.
pub camera: [f64; 3],
/// Its right and up, for what faces it.
pub right: [f32; 3],
pub up: [f32; 3],
/// Where the window's player looks, and their level left (first person).
pub forward: [f32; 3],
pub left: [f32; 3],
}
impl View {
pub fn new(camera: [f64; 3], right: [f32; 3], up: [f32; 3]) -> Self {
Self {
camera,
right,
up,
..Self::default()
}
}
/// With where the window's player looks and their level left.
pub fn looking(self, forward: [f32; 3], left: [f32; 3]) -> Self {
Self {
forward,
left,
..self
}
}
}
/// How a mesh or particles are lit.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Light {
/// As the player model is.
Model,
/// Fully lit: it glows.
Full,
/// By the world where the mesh's origin is (as particles: like [`Light::Model`]).
AtPlace,
}
/// How [`PlayerDraw::draw`] draws a mesh.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Look {
/// The sprite part it is textured by (u0, v0, u1, v1).
pub uv: [f32; 4],
pub light: Light,
/// Drawn see-through at this alpha, after every player, its back faces dropped.
pub see_through: Option<f32>,
}
impl Look {
/// Opaque, lit as the model.
pub fn new(uv: [f32; 4]) -> Self {
Self {
uv,
light: Light::Model,
see_through: None,
}
}
pub fn lit(self, light: Light) -> Self {
Self { light, ..self }
}
pub fn see_through(self, alpha: f32) -> Self {
Self {
see_through: Some(alpha),
..self
}
}
}
/// A quad that faces the camera ([`PlayerDraw::particles`]).
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct Particle {
/// Its corners, relative to the camera.
pub corners: [[f32; 3]; 4],
/// Each corner's place across the sprite part (0 to 1).
pub uv: [[f32; 2]; 4],
pub alpha: f32,
}
impl Particle {
pub fn new(corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], alpha: f32) -> Self {
Self { corners, uv, alpha }
}
}
/// The poses of a [`Mesh`]'s parts for one [`PlayerDraw::draw`], each at rest to begin with.
pub trait PartPoses {
/// Part `name`'s pose, to change (see [`Mesh::pose`]); a name the mesh lacks gets a pose that
/// is not used.
fn pose(&mut self, name: &str) -> &mut [f32; 9];
}
/// Where a crate draws on a player model ([`crate::Hooks::player_layers`]). Matrices are
/// column-major, relative to the camera, in blocks.
pub trait PlayerDraw {
fn pass(&self) -> Pass;
/// The model's age in ticks, with the frame's part of a tick.
fn ticks(&self) -> f32;
/// The model's own matrix, its origin at the neck (y down, 16 units a block).
fn base(&self) -> [[f32; 4]; 4];
fn motion(&self) -> Motion;
fn view(&self) -> View;
/// Where `bone` is as the body is drawn this frame; None if the model has no such part (or
/// no body is drawn: [`Pass::FirstPerson`]).
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]>;
/// The pose laid over the model's body ([`PlayerModels::set_body_pose`]), if any.
fn body_pose(&self) -> Option<&Arc<dyn BodyPose>>;
/// As [`Sprites::get`].
fn sprite(&self, id: u64) -> Option<[f32; 4]>;
/// Draws `mesh` at `matrix` as `look` says; `pose` poses its parts first.
fn draw(
&mut self,
mesh: &Arc<Mesh>,
matrix: [[f32; 4]; 4],
look: Look,
pose: &mut dyn FnMut(&mut dyn PartPoses),
);
/// Draws `particles`, textured by the sprite part `uv`, see-through after every player.
fn particles(&mut self, uv: [f32; 4], particles: &[Particle], light: Light);
}
+9 -1
View File
@@ -46,7 +46,15 @@ pub use horizon_server_files as server_files;
/// [`hooks::register`] it before the game starts (docs/layers.md, "How a Horizon crate plugs
/// in"). The engine's dispatch side is not part of the API.
pub mod hooks {
pub use horizon_hooks::{Hooks, Started, TooLate, register, tunnel};
/// The players the game shows (`horizon_hooks::players`): the views [`Hooks::frame`] and
/// [`Hooks::tick`] get, the pose a crate lays over a player's body, what goes by their names,
/// the crate's sprites, the camera, and what a crate draws on them.
pub use horizon_hooks::players::{
BODY_PARTS, Badge, BodyPose, Bone, CameraOverride, CameraView, Frame, Gear, Kind, Light,
Look, Mark, Mesh, Motion, NameDecoration, PartPoses, Particle, Pass, PlayerDraw,
PlayerModel, PlayerModels, Quad, SpriteStore, Sprites, View,
};
pub use horizon_hooks::{Hooks, Started, TooLate, register, seat, tunnel};
}
/// The settings store (`horizon_hooks::settings`): a crate's own `key:value` lines in the game
-267
View File
@@ -1,267 +0,0 @@
//! The numeric fast path: a program of one expression whose every part is a number (constants,
//! arithmetic, comparisons of numbers, math functions, ternaries and `root.method(...)` calls) is
//! compiled again into a tree of `f64`s, which runs without the interpreter's `Value`s and
//! `Result`s. It answers exactly what the interpreter would: anything that could tell them apart
//! (strings, objects, `==` on a method's value, locals, game functions) keeps the interpreter.
use super::parse::{BinOp, Expr, Stmt};
use super::{Field, Host, MathFn, Root, Value};
use std::sync::Arc;
/// What a node gives: always a number, always a boolean, or whatever a host method answers.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Kind {
Num,
Bool,
Unknown,
}
/// A method argument, handed to the host as the interpreter would: a constant of any kind
/// (`o.custom('glow')`), another method's own value, or a number or boolean.
#[derive(Debug)]
pub(super) enum Arg {
Value(Value),
Method(Num),
Num(Num, bool),
}
#[derive(Debug)]
pub(super) enum Num {
Const(f64),
Neg(Box<Num>),
Not(Box<Num>),
Bin(BinOp, Box<Num>, Box<Num>),
And(Box<Num>, Box<Num>),
Or(Box<Num>, Box<Num>),
Cond(Box<Num>, Box<Num>, Box<Num>),
Call(MathFn, Vec<Num>),
Method(Root, Arc<str>, Vec<Arg>),
/// A method without arguments the host may answer by [`Field`] (its name otherwise).
Field(Root, Field, Arc<str>),
}
/// The fast form of a whole program, with the kind of value it gives.
#[derive(Debug)]
pub(super) struct Fast {
root: Num,
kind: Kind,
}
impl Fast {
/// The fast form of a program, when it is one expression the fast path covers.
pub(super) fn of(body: &[Stmt], locals: usize) -> Option<Self> {
let [Stmt::Expr(e)] = body else {
return None;
};
if locals != 0 {
return None;
}
let root = Num::of(e)?;
let kind = root.kind();
// A method at the top hands back its own value; elsewhere the kind must be known.
(kind != Kind::Unknown || matches!(root, Num::Method(..) | Num::Field(..)))
.then_some(Self { root, kind })
}
pub(super) fn eval(&self, host: &mut dyn Host) -> Result<Value, String> {
match &self.root {
Num::Method(root, name, args) => return call(host, *root, name, args),
Num::Field(root, field, name) => return field_value(host, *root, *field, name),
_ => {}
}
let n = self.root.eval(host)?;
Ok(match self.kind {
Kind::Bool => Value::Bool(n != 0.0),
_ => Value::Num(n),
})
}
}
fn field_value(host: &mut dyn Host, root: Root, field: Field, name: &str) -> Result<Value, String> {
match host.field(root, field) {
Some(value) => Ok(value),
None => call(host, root, name, &[]),
}
}
fn call(host: &mut dyn Host, root: Root, name: &str, args: &[Arg]) -> Result<Value, String> {
const FEW: usize = 4;
let target = host.root(root);
if args.is_empty() {
return host.method(&target, name, &[]);
}
let mut values: [Value; FEW] = std::array::from_fn(|_| Value::Null);
if args.len() > FEW {
let all = args
.iter()
.map(|a| a.value(host))
.collect::<Result<Vec<_>, _>>()?;
return host.method(&target, name, &all);
}
for (slot, arg) in values.iter_mut().zip(args) {
*slot = arg.value(host)?;
}
host.method(&target, name, &values[..args.len()])
}
impl Arg {
fn value(&self, host: &mut dyn Host) -> Result<Value, String> {
Ok(match self {
Self::Value(v) => v.clone(),
Self::Method(Num::Method(root, name, args)) => call(host, *root, name, args)?,
Self::Method(Num::Field(root, field, name)) => field_value(host, *root, *field, name)?,
Self::Method(n) | Self::Num(n, false) => Value::Num(n.eval(host)?),
Self::Num(n, true) => Value::Bool(n.eval(host)? != 0.0),
})
}
}
/// Whether a subtree calls the host, whose answer may be no number.
fn has_method(e: &Expr) -> bool {
match e {
Expr::Method(..) => true,
Expr::Neg(a) | Expr::Not(a) => has_method(a),
Expr::Binary(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => has_method(a) || has_method(b),
Expr::Cond(c, a, b) => has_method(c) || has_method(a) || has_method(b),
Expr::Call(_, args) | Expr::Host(_, args) => args.iter().any(has_method),
_ => false,
}
}
impl Num {
fn of(e: &Expr) -> Option<Self> {
let boxed = |e: &Expr| Self::of(e).map(Box::new);
Some(match e {
Expr::Const(Value::Num(n)) => Self::Const(*n),
Expr::Const(_) | Expr::Local(_) | Expr::Root(_) | Expr::Host(..) => return None,
Expr::Neg(a) => Self::Neg(boxed(a)?),
Expr::Not(a) => Self::Not(boxed(a)?),
Expr::Binary(op, a, b) => {
// `==` and `!=` also compare strings and objects: only between plain numbers.
if matches!(op, BinOp::Eq | BinOp::Ne) && (has_method(a) || has_method(b)) {
return None;
}
Self::Bin(*op, boxed(a)?, boxed(b)?)
}
Expr::And(a, b) => Self::And(boxed(a)?, boxed(b)?),
Expr::Or(a, b) => Self::Or(boxed(a)?, boxed(b)?),
Expr::Cond(c, a, b) => Self::Cond(boxed(c)?, boxed(a)?, boxed(b)?),
Expr::Call(f, args) => {
Self::Call(*f, args.iter().map(Self::of).collect::<Option<_>>()?)
}
Expr::Method(target, name, args) => {
let Expr::Root(root) = **target else {
return None;
};
if let (true, Some(field)) = (args.is_empty(), Field::of(name)) {
return Some(Self::Field(root, field, name.clone()));
}
let args = args
.iter()
.map(|a| match a {
Expr::Const(v) => Some(Arg::Value(v.clone())),
other => {
let n = Self::of(other)?;
match n.kind() {
Kind::Unknown
if matches!(n, Self::Method(..) | Self::Field(..)) =>
{
Some(Arg::Method(n))
}
// A ternary between method values: the interpreter's own value.
Kind::Unknown => None,
kind => Some(Arg::Num(n, kind == Kind::Bool)),
}
}
})
.collect::<Option<_>>()?;
Self::Method(root, name.clone(), args)
}
})
}
fn kind(&self) -> Kind {
match self {
Self::Const(_) | Self::Neg(_) | Self::Call(..) => Kind::Num,
Self::Bin(op, ..) => match op {
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge => Kind::Bool,
_ => Kind::Num,
},
Self::Not(_) | Self::And(..) | Self::Or(..) => Kind::Bool,
Self::Cond(_, a, b) => match (a.kind(), b.kind()) {
(x, y) if x == y => x,
_ => Kind::Unknown,
},
Self::Method(..) | Self::Field(..) => Kind::Unknown,
}
}
/// The value as a number, as the interpreter's `Value::num` reads it.
fn eval(&self, host: &mut dyn Host) -> Result<f64, String> {
Ok(match self {
Self::Const(n) => *n,
Self::Neg(a) => -a.eval(host)?,
Self::Not(a) => f64::from(u8::from(!a.truthy(host)?)),
Self::Bin(op, a, b) => {
let (x, y) = (a.eval(host)?, b.eval(host)?);
let t = |b: bool| f64::from(u8::from(b));
match op {
BinOp::Add => x + y,
BinOp::Sub => x - y,
BinOp::Mul => x * y,
BinOp::Div => x / y,
BinOp::Rem => x % y,
BinOp::Pow => x.powf(y),
BinOp::Eq => t(x == y),
BinOp::Ne => t(x != y),
BinOp::Lt => t(x < y),
BinOp::Le => t(x <= y),
BinOp::Gt => t(x > y),
BinOp::Ge => t(x >= y),
}
}
Self::And(a, b) => f64::from(u8::from(a.truthy(host)? && b.truthy(host)?)),
Self::Or(a, b) => f64::from(u8::from(a.truthy(host)? || b.truthy(host)?)),
Self::Cond(c, a, b) => {
if c.truthy(host)? {
a.eval(host)?
} else {
b.eval(host)?
}
}
Self::Call(f, args) if args.len() <= 4 => {
let mut nums = [0.0; 4];
for (slot, arg) in nums.iter_mut().zip(args) {
*slot = arg.eval(host)?;
}
f(&nums[..args.len()])
}
Self::Call(f, args) => {
let nums = args
.iter()
.map(|a| a.eval(host))
.collect::<Result<Vec<_>, _>>()?;
f(&nums)
}
Self::Method(root, name, args) => call(host, *root, name, args)?.num(),
Self::Field(root, field, name) => field_value(host, *root, *field, name)?.num(),
})
}
/// The value's truth, as the interpreter's `Value::truthy` reads it: a method's own answer
/// (a string or an object is true), else whether the number isn't 0.
fn truthy(&self, host: &mut dyn Host) -> Result<bool, String> {
match self {
Self::Method(root, name, args) => Ok(call(host, *root, name, args)?.truthy()),
Self::Field(root, field, name) => Ok(field_value(host, *root, *field, name)?.truthy()),
Self::Cond(c, a, b) => {
if c.truthy(host)? {
a.truthy(host)
} else {
b.truthy(host)
}
}
other => Ok(other.eval(host)? != 0.0),
}
}
}
+1 -50
View File
@@ -3,7 +3,6 @@
//! loads, with every name resolved, and then evaluated against a [`Host`], the game side that
//! answers context objects (`o`, `p`, `g`...) and their methods.
mod colors;
mod fast;
mod legacy;
mod lex;
mod parse;
@@ -69,36 +68,6 @@ impl Root {
}
}
/// A method without arguments that expressions read most, resolved when the program compiles so
/// a host can answer it without comparing names ([`Host::field`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Field {
Age,
Life,
Custom,
X,
Y,
Z,
Temperature,
Downfall,
}
impl Field {
pub(crate) fn of(name: &str) -> Option<Self> {
Some(match name {
"age" => Self::Age,
"life" => Self::Life,
"custom" => Self::Custom,
"x" => Self::X,
"y" => Self::Y,
"z" => Self::Z,
"temperature" => Self::Temperature,
"downfall" => Self::Downfall,
_ => return None,
})
}
}
/// What the game answers while an expression runs.
pub trait Host {
fn root(&mut self, root: Root) -> Value;
@@ -107,12 +76,6 @@ pub trait Host {
fn method(&mut self, target: &Value, name: &str, args: &[Value]) -> Result<Value, String>;
/// A global function of the game's (`config`, `colormap`, `modOn`, `date*`).
fn function(&mut self, name: &str, args: &[Value]) -> Result<Value, String>;
/// `root.field()` answered without its name, or `None` to answer it through [`Self::method`]:
/// a host that answers must give what `method` would.
fn field(&mut self, root: Root, field: Field) -> Option<Value> {
let _ = (root, field);
None
}
}
type MathFn = fn(&[f64]) -> f64;
@@ -176,16 +139,13 @@ pub(crate) const MATH: &[(&str, usize, usize, MathFn)] = &[
pub struct Program {
body: Vec<Stmt>,
locals: usize,
/// The same program on the numeric fast path, when it is one (`fast.rs`).
fast: Option<fast::Fast>,
}
impl Program {
/// Parses `src`; a syntax error or an unknown name fails here.
pub fn compile(src: &str) -> Result<Self, String> {
let (body, locals) = parse::parse(src)?;
let fast = fast::Fast::of(&body, locals);
Ok(Self { body, locals, fast })
Ok(Self { body, locals })
}
/// Whether the program reads nothing of its context but the `root.name()` methods `allowed`
@@ -225,15 +185,6 @@ impl Program {
/// Runs the statements; the result is the last statement's value (an assignment's is the
/// value it stored).
pub fn eval(&self, host: &mut dyn Host) -> Result<Value, String> {
if let Some(fast) = &self.fast {
return fast.eval(host);
}
self.interpret(host)
}
/// Runs the statements through the interpreter (what [`Self::eval`] does for a program the
/// fast path doesn't cover).
fn interpret(&self, host: &mut dyn Host) -> Result<Value, String> {
let mut run = Run {
host,
locals: vec![Value::Null; self.locals],
-111
View File
@@ -231,114 +231,3 @@ fn reads_only_names_what_a_program_may_read() {
assert!(!ok("r.noise(o.y, 0)"));
assert!(!ok("config('pack:option')"));
}
/// A host whose methods answer every kind of value, and record the arguments they get.
struct Kinds {
age: f64,
seen: Vec<String>,
/// Answers `o.age` and `o.life` by field too, as the particles' host does.
fields: bool,
}
impl Host for Kinds {
fn root(&mut self, root: Root) -> Value {
match root {
Root::Object => Value::Object(1),
Root::Random => Value::Object(2),
_ => Value::Object(3),
}
}
fn method(&mut self, _: &Value, name: &str, args: &[Value]) -> Result<Value, String> {
self.seen.push(format!("{name}{args:?}"));
Ok(match name {
"age" => Value::Num(self.age),
"life" => Value::Num(140.0),
"custom" => Value::Num(if args.is_empty() { 0.3 } else { 2.0 }),
"inWater" => Value::Bool(self.age > 50.0),
"biome" => Value::Str("minecraft:plains".into()),
"empty" => Value::Str("".into()),
"me" => Value::Object(7),
"noise" => Value::Num(args.iter().map(Value::num).sum::<f64>().sin()),
"fail" => return Err("no such thing".into()),
_ => Value::Null,
})
}
fn function(&mut self, _: &str, _: &[Value]) -> Result<Value, String> {
Ok(Value::Null)
}
fn field(&mut self, root: Root, field: Field) -> Option<Value> {
if !self.fields || root != Root::Object {
return None;
}
match field {
Field::Age => Some(Value::Num(self.age)),
Field::Life => Some(Value::Num(140.0)),
_ => None,
}
}
}
#[test]
fn the_fast_path_answers_what_the_interpreter_does() {
let corpus = [
"(0.7 + 0.3*sin(o.age*0.18 + o.custom*13000)) * min(o.age/(o.life*0.15),1)",
"0.5+(r.noise(o.age/60,5+o.age/60)*0.55)",
"o.age > 40",
"o.age >= 40 && o.age < 90",
"!(o.age > 3)",
"o.age > 10 || o.fail",
"o.age > 70 ? 1 : 0.25",
"o.age > 70 ? o.age : o.life",
"o.age > 70 ? o.biome : o.life",
"o.inWater",
"o.inWater ? 2 : 3",
"!o.inWater",
"o.biome ? 1 : 0",
"o.empty ? 1 : 0",
"o.me ? 1 : 0",
"o.biome + 1",
"-o.inWater",
"o.custom('glow') + 1",
"o.custom(o.age > 3)",
"r.noise(o.inWater, 2)",
"o.fail + 1",
"o.age % 7 - 2 ^ 3",
"clamp(o.age, 10, 20) / 0",
"o.age == 3",
"2 == 2",
"1 != 2 ? o.age : 0",
"max(o.age, o.life) - min(o.age, 1)",
"o.age > 3 ? o.age > 5 : o.inWater",
];
for src in corpus {
let program = Program::compile(src).unwrap();
let cases = [0.0, 3.0, 45.0, 75.0, 139.0]
.into_iter()
.flat_map(|a| [(a, false), (a, true)]);
for (age, fields) in cases {
let mut fast_host = Kinds {
age,
seen: Vec::new(),
fields,
};
let mut slow_host = Kinds {
age,
seen: Vec::new(),
fields,
};
let fast = program.eval(&mut fast_host);
let slow = program.interpret(&mut slow_host);
let same = match (&fast, &slow) {
(Ok(Value::Num(a)), Ok(Value::Num(b))) => a.to_bits() == b.to_bits(),
(a, b) => a == b,
};
assert!(same, "{src} at {age}: fast {fast:?}, interpreter {slow:?}");
// A field the host answers skips its call; the interpreter makes it by name.
if !fields {
assert_eq!(fast_host.seen, slow_host.seen, "{src} at {age}: the calls");
}
}
}
// The fast path covers the particles' and colormaps' plain arithmetic.
assert!(Program::compile(corpus[0]).unwrap().fast.is_some());
assert!(Program::compile(corpus[1]).unwrap().fast.is_some());
}
+1 -1
View File
@@ -45,7 +45,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider, but its peak is mostly the display's EDID figure, which often runs high: Auto still uses it, over the 1000-nit default, but the HDR Line truncated
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' luminance readings often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Line truncated
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
+1 -1
View File
@@ -20,7 +20,7 @@ plan are in [server.md](server.md).
takes the account's UUID when someone owns that name, as vanilla does, and that player is then refused.
- **An offline server** (`online_mode = false`) never checks accounts: anyone who reaches it can join under
any name. Keep it on your LAN or a tailnet (Tailscale, ZeroTier), or share it only with friends you trust.
A Horizon game started without a session (no network at Play) can join only such a server.
A Horizon player under an offline name can join only such a server.
- **Secure chat** works as on vanilla's server: in online mode, vanilla clients and Horizon's (Nightly
2026-11-01 and newer) sign their chat, and with `enforce-secure-profile=true` (the default) unsigned chat is
refused. On an offline server, or for a player without a chat session, chat stays unsigned and shows as not
+36 -1
View File
@@ -188,7 +188,7 @@ MCP: `account_sign_out`, needs `confirm: true`.
## `horizon-launcher account select`
Play as this account.
Play as this account, or as one of your offline names.
horizon-launcher account select <WHO>
@@ -196,6 +196,41 @@ Play as this account.
MCP: `account_select`.
## `horizon-launcher account offline`
Play offline (LAN, offline-mode servers) beside a signed-in account that owns the game: as NAME (kept to switch back to; 3 to 16 letters, digits or _), or as the name the game made up.
horizon-launcher account offline [NAME]
- `NAME`
MCP: `account_offline`.
## `horizon-launcher account forget-offline`
Forget one of your offline names; its skin and cape go with it.
horizon-launcher account forget-offline <NAME>
- `NAME`
MCP: `account_forget_offline`.
## `horizon-launcher account skin`
Show or set an offline name's skin and cape: a 64x64 (or 64x32) skin PNG, a 64x32 cape PNG, the model. Kept by the launcher and given to the game when that name plays.
horizon-launcher account skin <NAME> [FILE]
- `NAME`
- `FILE`: The skin's PNG
- `--model`: classic or slim arms
- `--cape`: The cape's PNG
- `--no-skin`: Back to the default skin
- `--no-cape`: Take the cape off
MCP: `account_skin`.
## `horizon-launcher settings`
Show the launcher's settings, or set one (the Settings page): selected-version TAG, game-architecture auto|x64|arm64, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, prune-old-versions on|off (delete old versions after an install, keeping the selected one, the ones instances use and the newest few), keep-versions N (how many of the newest to keep; "default" is 2), opening-animation on|off, proxy http://host:port|socks5://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru|de|zh|es|uk|pl (the window's language; system follows the computer's), accent violet|blue|teal|green|amber|red|pink|#rrggbb|DEGREES (a named accent, any colour, or any hue), background PATH (a picture behind Home; "default" shows Horizon's landscape), native-cache-gib N (combined native compilation cache limit in GiB, default 30; 0 disables automatic cleanup), native-profile performance|dev|release (the profile native mods build with: performance is incremental, dev rebuilds a mod quickest for a slow game, release is slowest to build and slightly faster to play).
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more