Compare commits

..
Author SHA1 Message Date
Horizon agent 40f82d0e99 Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-telemetry-server/Cargo.lock
2026-11-09 20:38:46 +01:00
Horizon agent 7433b49bef Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
#	docs/layers.md
2026-11-09 20:05:34 +01:00
Horizon agent 9f5ed29593 Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:42:41 +01:00
Horizon agentandClaude Opus 5.5 b7bf458117 horizon-telemetry-server's Cargo.lock: horizon-platform uses horizon-core (stale on main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:26:15 +01:00
Horizon agent 5062adfb0c Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 19:11:07 +01:00
Horizon agentandClaude Opus 5.5 37bdbf9def spans::startup::since_start in the startup module; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:39:04 +01:00
Horizon agentandClaude Opus 5.5 55b78015fe docs/layers.md: the hooks paragraph rewrapped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:57 +01:00
Horizon agentandClaude Opus 5.5 cb1198a38d Telemetry's hooks register only with horizon-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:46 +01:00
Horizon agentandClaude Opus 5.5 17b50d7959 Telemetry's play session hears the game through the hooks (layer plan M5)
horizon-hooks gains the play session's hooks: startup (core-ready, rest-ready,
the title's first frame, once each), wants_frame_steps, world_shown, frame and
world_left, with plain data (Frame, WorldLeft, Milestone). The engine's
play_session::Session marks the startup trace and dispatches them; Game and
the startup menu hold it instead of telemetry's Session.

telemetry.rs implements them (HOOKS, registered by entry.rs like the ping):
the consent check for frame steps, the settings' encoding and the start's
timings move there, so gameplay no longer names horizon_telemetry for the
perf session.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:39 +01:00
263 changed files with 6776 additions and 7961 deletions

No files matched your search

+1
View File
@@ -13,3 +13,4 @@
87152661 rdbtCVS # added by Ludovic, 2026-10-27
84875247 Online5277 # added by Ludovic, 2026-10-27 (his top three by merged pull requests)
86072227 levzzz5154 # added by Ludovic, 2026-10-28 (Horizon Team; author of horizon-mixins)
169198624 itarqos5 # added by Ludovic, 2026-11-08 (Discord Literal; Horizon Team)
-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
-10
View File
@@ -1796,21 +1796,11 @@ dependencies = [
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-official-servers",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-official-servers"
version = "0.1.0"
dependencies = [
"horizon-addresses",
"horizon-platform",
"log",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
-1
View File
@@ -34,7 +34,6 @@ members = [
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-official-servers",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
-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.
-4
View File
@@ -15,10 +15,6 @@ pub const OFFICIAL_SERVER: &str = "play.playhorizon.dev";
/// `official_server_previous` in `launcher/links.json`).
pub const OFFICIAL_SERVER_PREVIOUS: &[&str] = &["horizon.bunchy7s.dev"];
/// Afri-Network, a partner server the game added once below the official server (#9299) until
/// the partnership ended: kept so the game can take back out the entry it added.
pub const WITHDRAWN_PARTNER_SERVER: &str = "afri-network.co.za";
/// [`OFFICIAL_SERVER`] with the port its SRV record gives, for askers that don't look SRV up
/// (the telemetry server's status checks).
pub const OFFICIAL_SERVER_STATUS: &str = "play.playhorizon.dev:24454";
@@ -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;
+2 -1
View File
@@ -115,7 +115,8 @@ default = ["audio", "controllers", "horizon-extras"]
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# Horizon's own parts still in this crate (docs/forking.md, docs/layers.md): the features below.
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
-4
View File
@@ -76,10 +76,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
}
}
@@ -386,7 +386,6 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
for _ in 0..32 {
draw(&mut renderer)?;
@@ -207,7 +207,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("cpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
let expected = read(&out.join("cpu.png"));
@@ -252,7 +251,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: out.join("gpu.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame)?;
assert_eq!(
@@ -167,7 +167,6 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
path: path.clone(),
size: None,
scaled: false,
background: false,
});
// twice: the first frame of a new warp builds it
renderer.render(&frame).unwrap();
@@ -337,7 +336,6 @@ fn bench(renderer: &mut Renderer, out: &Path) -> Result<(), Box<dyn std::error::
path: out.join("bench.png"),
size: None,
scaled: false,
background: false,
});
renderer.render(&frame).unwrap();
renderer.render(&frame).unwrap();
+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 {
@@ -112,11 +112,7 @@ fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
};
Row::new(id, lang::get(label), value)
}
added::Row::Button {
label,
active: true,
..
} => Row::new(id, lang::get(label), Value::Press),
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
@@ -494,7 +490,9 @@ mod tests {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::button("test.elsewhere.button"));
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
}
fn act(&self, _: usize, _: Act) {}
}
-53
View File
@@ -233,8 +233,6 @@ pub struct FogInputs<'a> {
pub night_vision: f32,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
/// The packs' fluid modifiers' fog.
pub fluid_fog: crate::pack_palette::FluidFogs,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -399,24 +397,6 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
}
}
}
// A pack's fluid modifier over its fluid's fog, after everything else (wiki "Custom Colors").
let fluid = match input.fog_type {
FogType::Lava => input.fluid_fog.lava,
FogType::Water if input.blindness.is_none() && input.darkness.is_none() => {
input.fluid_fog.water
}
_ => None,
};
if let Some(f) = fluid {
if let Some(rgb) = f.color {
let [r, g, b] = self::rgb(rgb);
fog.color = [r, g, b, 1.0];
}
let end = fog.environmental_end * f.radius;
let start = end - (end - fog.environmental_start * f.radius) * f.fade;
(fog.environmental_start, fog.environmental_end) = (start, end);
(fog.sky_end, fog.cloud_end) = (end, end);
}
let span = (rd / 10.0).clamp(4.0, 64.0);
fog.render_distance_start = rd - span;
fog.render_distance_end = rd;
@@ -515,7 +495,6 @@ mod tests {
full_water_vision: false,
night_vision: 0.0,
environment: Default::default(),
fluid_fog: Default::default(),
}
}
#[test]
@@ -632,38 +611,6 @@ mod tests {
let kept = setup_fog(&tables, &state, &input).color;
assert_eq!(&kept[..3], &[0.1, 0.2, 0.4]);
}
#[test]
fn a_fluid_modifier_scales_and_colours_its_fog_last() {
let sky = Sky {
fog_start: 10.0,
fog_end: 96.0,
..Sky::default()
};
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.fog_type = FogType::Lava;
input.fire_resistance = true;
let vanilla = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(
(vanilla.environmental_start, vanilla.environmental_end),
(0.0, 5.0)
);
input.fluid_fog.lava = Some(crate::pack_palette::FluidFog {
color: Some(0x00FF00),
radius: 2.0,
fade: 0.5,
});
let fog = setup_fog(&tables, &FogState::default(), &input);
assert_eq!(fog.color, [0.0, 1.0, 0.0, 1.0]);
// The end doubles; half the fade from 0 to 10 starts it at 5.
assert_eq!(
(fog.environmental_start, fog.environmental_end),
(5.0, 10.0)
);
assert_eq!((fog.sky_end, fog.cloud_end), (10.0, 10.0));
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: crate::pack_palette::Environment,
+107 -40
View File
@@ -201,7 +201,6 @@ pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
mod hold;
pub use hold::WorldHold;
pub use output::{DisplayReading, EffectiveOutput, FrameConfiguration, FrameRequest};
pub use screenshot_save::Saved as SavedScreenshot;
pub mod block_light;
mod custom_sky;
mod displace;
@@ -237,7 +236,6 @@ pub mod panorama;
mod readback;
mod resident;
mod reuse;
mod screenshot_save;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
@@ -1086,9 +1084,6 @@ pub struct ScreenshotRequest {
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
/// Written on a thread of its own (F2's, the world icon's): its outcome comes back a
/// frame or a few later in `Renderer::saved_screenshots`, not `screenshot_result`.
pub background: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
@@ -1583,9 +1578,6 @@ pub struct Renderer {
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// The HDR copy the last screenshot wrote beside it, if it wrote one.
pub screenshot_hdr: Option<std::path::PathBuf>,
/// `background` screenshots written since the caller last took them.
pub saved_screenshots: Vec<SavedScreenshot>,
screenshot_saves: screenshot_save::Saves,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
@@ -5085,7 +5077,6 @@ impl Renderer {
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
let screenshot_saves = screenshot_save::Saves::new(&device);
Ok(Self {
display: None,
reading: display,
@@ -5357,8 +5348,6 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
screenshot_hdr: None,
saved_screenshots: Vec::new(),
screenshot_saves,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
@@ -7052,10 +7041,8 @@ impl Renderer {
}
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> output::EffectiveOutput {
let native = self.native.as_ref().map(|n| n.frame.api);
self.output
.with_pack(self.shader_pack_kind() == Some("iris"), native)
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
}
/// What this renderer's display last reported: a renderer made again for the window
/// starts from it (`Renderer::new`'s `display`).
@@ -7988,8 +7975,6 @@ impl Renderer {
if self.device_lost() {
return Err("the graphics device was lost".into());
}
let saved = self.screenshot_saves.finished();
self.saved_screenshots.extend(saved);
if let Some(request) = self.capture.take() {
let result = self.write_capture(&request, frame);
self.capture_result = Some(result.map_err(|e| e.to_string()));
@@ -8334,7 +8319,9 @@ impl Renderer {
let taa = !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.as_ref().is_none_or(|n| n.post.is_none());
let hdr_lighting = self.shader_pack_kind().is_none();
let hdr_lighting = self.native.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting && hdr.look;
}
@@ -12057,18 +12044,24 @@ impl Renderer {
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
let size = [self.config.width, self.config.height];
let hdr = hdr_readback
.filter(|_| self.hdr.is_some())
.map(|b| (hdr_screenshot_path(&request.path), b));
let shot = screenshot_save::Shot::new(&request, size, self.config.format, hdr);
if request.background {
self.screenshot_saves.start(shot, buffer);
} else {
let saved = screenshot_save::save_now(&self.device, shot, &buffer);
self.screenshot_hdr = saved.hdr;
self.screenshot_result = Some(saved.result);
self.screenshot_hdr = None;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
self.screenshot_hdr = Some(hdr_path);
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
}
// The frame's own waits for the submit thread (its queue writes and passes).
@@ -12671,22 +12664,96 @@ impl Renderer {
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
let error = format!(
self.screenshot_result = Some(Err(format!(
"screenshots need a 32-bit surface format, not {:?}",
self.config.format
);
if request.background {
self.saved_screenshots.push(screenshot_save::Saved {
path: request.path,
result: Err(error),
hdr: None,
});
} else {
self.screenshot_result = Some(Err(error));
}
)));
None
}
}
/// The pixels `copy_frame` read back into `buffer`, 4 bytes each, row after row.
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<Vec<[u8; 4]>> {
let (sender, receiver) = std::sync::mpsc::channel();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = sender.send(r);
});
self.device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string())?;
receiver
.recv()
.map_err(|e| e.to_string())?
.map_err(|e| e.to_string())?;
let (width, height, row) = (
self.config.width as usize,
self.config.height as usize,
padded_row(self.config.width) as usize,
);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_screenshot(
&self,
request: &ScreenshotRequest,
buffer: &wgpu::Buffer,
) -> Result<[u32; 2]> {
let bgra = matches!(
self.config.format.remove_srgb_suffix(),
wgpu::TextureFormat::Bgra8Unorm
);
let mut pixels = Vec::with_capacity((self.config.width * self.config.height * 4) as usize);
for px in self.read_frame(buffer)? {
let (r, b) = if bgra { (px[2], px[0]) } else { (px[0], px[2]) };
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match request.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, self.config.width, self.config.height, size),
),
None => (self.config.width, self.config.height, pixels),
};
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(&request.path)?),
width,
height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(&pixels)?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
if self.hdr.is_none() {
return Ok(());
}
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
let mut info = png::Info::with_size(self.config.width, self.config.height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let encoder =
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
let mut writer = encoder.write_header()?;
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)?;
Ok(())
}
}
/// `screenshots/2026-10-28_12.00.00.png` -> `screenshots/2026-10-28_12.00.00-hdr.png`.
fn hdr_screenshot_path(path: &std::path::Path) -> PathBuf {
+1 -1
View File
@@ -191,7 +191,7 @@ pub struct Hdr {
pub masks: Masks,
/// HDR lighting, whether Horizon lifts this frame's world: off under a Horizon shader pack
/// that draws, while a `shaders/` pack's post or material stage runs (the pack's own),
/// and with HDR Lighting (`look`) off.
/// with HDR Lighting (`look`) off, and with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// The HDR Lighting setting: off, nothing in the world is lifted, light sources and the
/// sun included, so the picture is SDR's on the HDR canvas.
@@ -144,9 +144,6 @@ pub struct EffectiveOutput {
pub(crate) nits: [f32; 3],
/// The HUD and menus' brightness, as a share of paper white: their nits over its.
pub(crate) ui_brightness: f32,
/// The running shader pack lights the world itself: Horizon lifts none of it, so HDR
/// Lighting has nothing to switch (`with_pack`).
pub(crate) pack_lights: bool,
}
impl Default for EffectiveOutput {
fn default() -> Self {
@@ -169,22 +166,7 @@ impl EffectiveOutput {
},
nits: [203.0, 1000.0, 0.0],
ui_brightness: 1.0,
pack_lights: false,
};
/// This output under the running shader pack: a `shaders/` one (`shaders`), or one of
/// Horizon's own of API `native`. Under a 0.1 pack on the 8- or 16-bit frame the last pass
/// still lifts the light sources and the sun its marks hold (`lifts_world` in
/// `Renderer::render`); under the rest Horizon lifts nothing.
pub(crate) fn with_pack(self, shaders: bool, native: Option<horizon_shader_api::Api>) -> Self {
let pack_lights = shaders
|| native.is_some_and(|api| {
api != horizon_shader_api::Api::V0_1 || self.frame.format == super::LINEAR_FRAME
});
Self {
pack_lights,
..self
}
}
/// Whether the destination offers HDR output.
pub fn hdr_offered(&self) -> bool {
self.offered.is_some()
@@ -291,7 +273,6 @@ pub(crate) fn resolve_output(
],
ui_brightness: settings.ui_nits(reading.luminance) as f32
/ settings.paper_white_nits(reading.luminance) as f32,
pack_lights: false,
}
}
@@ -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());
}
}
@@ -1,321 +0,0 @@
//! Writing a screenshot's PNG once its frame is read back: on the render thread for the
//! requests that want the result with the frame (an agent's, a `screenshot-sequence`'s),
//! on a thread of its own for F2's and the world icon's (`ScreenshotRequest::background`).
//! The render thread then neither waits for the GPU to finish the frame nor encodes the
//! PNG, which at 4K or a Screenshot Quality above 1x froze the game for a second or more.
use super::{ScreenshotRequest, hdr_icc, hdr10_to_rgb16, padded_row, square_downscale};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
/// What a frame's readback holds and where its PNG goes.
pub(super) struct Shot {
pub path: PathBuf,
/// `ScreenshotRequest::size`: the world icon's square.
pub size: Option<[u32; 2]>,
/// The frame's size, which the readback's rows hold (padded to `padded_row`).
pub frame: [u32; 2],
/// Blue first (`Bgra8Unorm`), swapped to PNG's red first.
pub bgra: bool,
/// The HDR10 copy's readback and its PNG's path, beside the SDR one.
pub hdr: Option<(PathBuf, wgpu::Buffer)>,
}
impl Shot {
pub fn new(
request: &ScreenshotRequest,
frame: [u32; 2],
format: wgpu::TextureFormat,
hdr: Option<(PathBuf, wgpu::Buffer)>,
) -> Self {
Self {
path: request.path.clone(),
size: request.size,
frame,
bgra: format.remove_srgb_suffix() == wgpu::TextureFormat::Bgra8Unorm,
hdr,
}
}
}
/// A written screenshot: its path, its size or the error, and the HDR copy written beside it.
pub struct Saved {
pub path: PathBuf,
pub result: Result<[u32; 2], String>,
pub hdr: Option<PathBuf>,
}
/// Waits for `buffer`'s mapping on the render thread and writes the PNG there.
pub(super) fn save_now(device: &wgpu::Device, shot: Shot, buffer: &wgpu::Buffer) -> Saved {
let (sender, receiver) = channel();
map(buffer, &sender);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &sender);
}
let waited = device
.poll(wgpu::PollType::wait_indefinitely())
.map_err(|e| e.to_string());
let count = 1 + usize::from(shot.hdr.is_some());
let mapped = waited.and_then(|_| wait_mapped(&receiver, count));
write(shot, buffer, mapped)
}
/// Screenshots being written on their thread.
pub(super) struct Saves {
device: wgpu::Device,
sender: Sender<Saved>,
receiver: Receiver<Saved>,
in_flight: usize,
}
impl Saves {
pub fn new(device: &wgpu::Device) -> Self {
let (sender, receiver) = channel();
Self {
device: device.clone(),
sender,
receiver,
in_flight: 0,
}
}
/// Maps `buffer` (and the HDR copy's), whose copy was just submitted, and writes the
/// PNG on a thread of its own once the GPU has finished the frame.
pub fn start(&mut self, shot: Shot, buffer: wgpu::Buffer) {
let (mapped, on_mapped) = channel();
map(&buffer, &mapped);
if let Some((_, hdr)) = &shot.hdr {
map(hdr, &mapped);
}
let done = self.sender.clone();
let path = shot.path.clone();
let spawned = std::thread::Builder::new()
.name("Screenshot writer".into())
.spawn(move || {
let count = 1 + usize::from(shot.hdr.is_some());
let ready = wait_mapped(&on_mapped, count);
let _ = done.send(write(shot, &buffer, ready));
});
match spawned {
Ok(_) => {}
Err(e) => {
let _ = self.sender.send(Saved {
path,
result: Err(format!("no thread to write it: {e}")),
hdr: None,
});
}
}
self.in_flight += 1;
}
/// The screenshots written since the last call. Polls the device so a finished frame's
/// mapping reaches its writer: the render thread never waits for it.
pub fn finished(&mut self) -> Vec<Saved> {
if self.in_flight == 0 {
return Vec::new();
}
let _ = self.device.poll(wgpu::PollType::Poll);
let saved: Vec<Saved> = self.receiver.try_iter().collect();
self.in_flight -= saved.len();
saved
}
}
impl Drop for Saves {
/// A screenshot taken right before quitting is still written whole.
fn drop(&mut self) {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while self.in_flight > 0 && std::time::Instant::now() < deadline {
let _ = self.device.poll(wgpu::PollType::Poll);
if let Ok(saved) = self.receiver.recv_timeout(Duration::from_millis(20)) {
log_saved(&saved);
self.in_flight -= 1;
}
}
}
}
fn log_saved(saved: &Saved) {
match &saved.result {
Ok(_) => log::info!("Saved screenshot to {}", saved.path.display()),
Err(e) => log::warn!("Horizon renderer: screenshot {}: {e}", saved.path.display()),
}
}
fn map(buffer: &wgpu::Buffer, mapped: &Sender<Result<(), String>>) {
let mapped = mapped.clone();
buffer.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = mapped.send(r.map_err(|e| e.to_string()));
});
}
/// `count` mappings done, or the first one that failed. A frame the GPU never finishes
/// (a lost device) gives up after a while instead of keeping its thread.
fn wait_mapped(mapped: &Receiver<Result<(), String>>, count: usize) -> Result<(), String> {
for _ in 0..count {
mapped
.recv_timeout(Duration::from_secs(30))
.map_err(|_| "the frame never came back from the GPU".to_string())??;
}
Ok(())
}
/// The PNG (and the HDR copy's beside it) from the mapped readbacks.
fn write(shot: Shot, buffer: &wgpu::Buffer, mapped: Result<(), String>) -> Saved {
let mut hdr_written = None;
let result = mapped.and_then(|()| {
if let Some((hdr_path, hdr_buffer)) = &shot.hdr {
let pixels = read_rows(hdr_buffer, shot.frame);
match pixels.and_then(|p| write_hdr_png(hdr_path, shot.frame, &p)) {
Ok(()) => {
log::info!("Saved HDR screenshot to {}", hdr_path.display());
hdr_written = Some(hdr_path.clone());
}
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
let pixels = read_rows(buffer, shot.frame)?;
write_png(&shot, &pixels)
});
Saved {
path: shot.path,
result,
hdr: hdr_written,
}
}
/// The pixels `copy_frame` read back into the mapped `buffer`, 4 bytes each, row after
/// row; unmaps it.
fn read_rows(buffer: &wgpu::Buffer, [width, height]: [u32; 2]) -> Result<Vec<[u8; 4]>, String> {
let (width, height, row) = (width as usize, height as usize, padded_row(width) as usize);
let mut pixels = Vec::with_capacity(width * height);
{
let data = buffer.get_mapped_range(..).map_err(|e| e.to_string())?;
for y in 0..height {
pixels.extend_from_slice(data[y * row..y * row + width * 4].as_chunks::<4>().0);
}
}
buffer.unmap();
Ok(pixels)
}
fn write_png(shot: &Shot, frame: &[[u8; 4]]) -> Result<[u32; 2], String> {
let [frame_w, frame_h] = shot.frame;
let mut pixels = Vec::with_capacity(frame.len() * 4);
for px in frame {
let (r, b) = if shot.bgra {
(px[2], px[0])
} else {
(px[0], px[2])
};
pixels.extend_from_slice(&[r, px[1], b, 255]);
}
// NativeImage.resizeSubRectTo on takeAutoScreenshot's grab: the icon
// is the frame's centre square averaged down to its size.
let (width, height, pixels) = match shot.size {
Some(size) => (
size[0],
size[1],
square_downscale(&pixels, frame_w, frame_h, size),
),
None => (frame_w, frame_h, pixels),
};
let file = std::fs::File::create(&shot.path).map_err(|e| e.to_string())?;
let mut encoder = png::Encoder::new(std::io::BufWriter::new(file), width, height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.and_then(|mut w| w.write_image_data(&pixels))
.map_err(|e| e.to_string())?;
Ok([width, height])
}
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
/// third edition), with the content light levels its pixels hold (cLLI), and
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
fn write_hdr_png(
path: &std::path::Path,
[width, height]: [u32; 2],
frame: &[[u8; 4]],
) -> Result<(), String> {
let pixels = hdr10_to_rgb16(frame);
let mut info = png::Info::with_size(width, height);
info.color_type = png::ColorType::Rgb;
info.bit_depth = png::BitDepth::Sixteen;
info.icc_profile = Some(hdr_icc::profile().into());
let file = std::fs::File::create(path).map_err(|e| e.to_string())?;
let written = png::Encoder::with_info(std::io::BufWriter::new(file), info)
.and_then(|encoder| encoder.write_header())
.and_then(|mut writer| {
// BT.2020 primaries, PQ, RGB, full range.
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
writer.write_chunk(png::chunk::cLLI, &hdr_icc::content_light_levels(&pixels))?;
writer.write_image_data(&pixels)
});
written.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_background_save_writes_the_png_off_the_render_thread() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let dir = std::env::temp_dir().join(format!("horizon-shot-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("shot.png");
let [w, h] = [3u32, 2];
// A frame of blue-first pixels, each row padded as `copy_frame` reads them back.
let row = padded_row(w) as usize;
let mut bytes = vec![0u8; row * h as usize];
for y in 0..h as usize {
for x in 0..w as usize {
bytes[y * row + x * 4..][..4].copy_from_slice(&[10 * x as u8, 20, 200, 0]);
}
}
let source = wgpu::util::DeviceExt::create_buffer_init(
&device,
&wgpu::util::BufferInitDescriptor {
label: None,
contents: &bytes,
usage: wgpu::BufferUsages::COPY_SRC,
},
);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: bytes.len() as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(&source, 0, &buffer, 0, bytes.len() as u64);
queue.submit([encoder.finish()]);
let request = ScreenshotRequest {
path: path.clone(),
size: None,
scaled: false,
background: true,
};
let mut saves = Saves::new(&device);
saves.start(
Shot::new(&request, [w, h], wgpu::TextureFormat::Bgra8Unorm, None),
buffer,
);
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let saved = loop {
if let Some(saved) = saves.finished().pop() {
break saved;
}
assert!(std::time::Instant::now() < deadline, "never written");
std::thread::sleep(Duration::from_millis(5));
};
assert_eq!(saved.path, path);
assert_eq!(saved.result, Ok([w, h]));
let decoder =
png::Decoder::new(std::io::BufReader::new(std::fs::File::open(&path).unwrap()));
let mut reader = decoder.read_info().unwrap();
let mut out = vec![0; reader.output_buffer_size().unwrap()];
reader.next_frame(&mut out).unwrap();
// Red first, opaque: the last pixel of the first row was [20, 20, 200] blue-first.
assert_eq!(&out[8..12], &[200, 20, 20, 255]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -5,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"))]
@@ -1110,8 +1110,7 @@ impl Item {
/// active; saved values wait for it. Translucent Batching switches where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// Smooth Gradients only where it can draw the 16-bit frame, the same way, and GPU Culling
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't. HDR
/// Lighting has nothing to switch where the running shader pack lights the world itself.
/// only where it is offered (`graphics::gpu_culling_offer`), with the reason it isn't.
fn blocked(self, s: &Settings, out: &EffectiveOutput) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -1142,9 +1141,6 @@ impl Item {
} else {
"Turn HDR on to use it."
}),
Item::Horizon(H::HdrLighting) if out.pack_lights => {
Some("The shader pack lights the world itself.")
}
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -31,49 +31,6 @@ fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
assert_eq!(item.blocked(&s, &out), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_lighting_is_greyed_only_where_the_running_pack_lights_the_world() {
use horizon_shader_api::Api;
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let item = Item::Horizon(H::HdrLighting);
let lights = Some("The shader pack lights the world itself.");
let out = offering(true, None);
// No pack running, as with one picked but refused, missing or its mod off: live.
assert_eq!(item.blocked(&s, &out.with_pack(false, None)), None);
// A 0.1 pack on the 16-bit frame: the light sources and the sun still shine.
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_1))),
None
);
assert_eq!(
item.blocked(&s, &out.with_pack(false, Some(Api::V0_2))),
lights
);
assert_eq!(item.blocked(&s, &out.with_pack(true, None)), lights);
let float = EffectiveOutput {
frame: crate::gfx::FrameConfiguration {
format: wgpu::TextureFormat::Rgba16Float,
..out.frame
},
..out
};
assert_eq!(
item.blocked(&s, &float.with_pack(false, Some(Api::V0_1))),
lights
);
// HDR's own reasons come first.
assert_eq!(
item.blocked(&s, &NONE.with_pack(true, None)),
Some("Your display or system doesn't offer HDR output.")
);
s.horizon.hdr = HdrMode::Off;
assert_eq!(
item.blocked(&s, &out.with_pack(true, None)),
Some("Turn HDR on to use it.")
);
}
#[test]
fn hdr_auto_can_be_selected_before_querying_and_brightness_waits_for_output() {
let mut s = Settings::default();
@@ -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.
@@ -162,38 +162,10 @@ impl OptionsUi {
};
let mut rows = Vec::new();
registered.rows(&mut rows);
let mut out = Vec::new();
// The row a half-width button started, waiting for the next to sit beside it.
let mut half_open = false;
for (r, row) in rows.into_iter().enumerate() {
match row {
// Wrapped as the engine's own notes; space is for pages drawn as a screen.
added::Row::Text { text, .. } => {
out.extend(wrap(&text, 64).into_iter().map(Row::Note))
}
added::Row::Space(_) | added::Row::Field { .. } => continue,
added::Row::Button {
label,
active,
half: true,
..
} => {
let mut half = button(0.0, 0.0, 150.0, tr(label), Act::PageRow(page, r));
if !active {
half = inactive(half);
}
match out.last_mut() {
Some(Row::Buttons(_, second @ None)) if half_open => *second = Some(half),
_ => out.push(Row::Buttons(half, None)),
}
half_open = !half_open;
continue;
}
row => out.push(self.added_row(page, r, row)),
}
half_open = false;
}
out
rows.into_iter()
.enumerate()
.map(|(r, row)| self.added_row(page, r, row))
.collect()
}
fn added_row(&self, page: usize, r: usize, row: added::Row) -> Row {
@@ -205,10 +177,7 @@ impl OptionsUi {
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
added::Row::Button { label, active, .. } => {
let wide = button(0.0, 0.0, 310.0, tr(label), act);
Row::Buttons(if active { wide } else { inactive(wide) }, None)
}
added::Row::Button { label } => wide(tr(label)),
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
if self.selected_key == Some(PAGE_KEY)
@@ -492,84 +461,4 @@ mod tests {
Some(tr("test.sample.switch.tooltip").plain())
);
}
/// Text, a grey note, space, two half buttons (the second inactive) and a footer button.
struct Shaped(Mutex<Vec<usize>>);
impl added::Page for Shaped {
fn id(&self) -> &'static str {
"test.shaped"
}
fn title(&self) -> &'static str {
"test.shaped.title"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::text("ABCDE-FGHIJ").groups());
rows.push(added::Row::text("grey").quiet());
rows.push(added::Row::Space(6));
rows.push(added::Row::button("test.shaped.copy").half());
rows.push(added::Row::button("test.shaped.stop").half().active(false));
rows.push(added::Row::button("test.shaped.help").footer());
}
fn act(&self, row: usize, _: added::Act) {
self.0.lock().unwrap().push(row);
}
}
static SHAPED: Shaped = Shaped(Mutex::new(Vec::new()));
static SHAPED_PAGES: [&dyn added::Page; 1] = [&SHAPED];
#[test]
fn half_buttons_share_a_row_and_an_inactive_one_is_never_pressed() {
let mut s = Settings::default();
TEST_PAGES.set(Some(&SHAPED_PAGES));
let ui = OptionsUi::default();
let rows = ui.added_rows(0);
// The two texts as notes (space shows nothing here), the halves on one row, the
// footer button below them (the settings shell has no footer of its own).
assert!(matches!(&rows[0], Row::Note(t) if t == "ABCDE-FGHIJ"));
assert!(matches!(&rows[1], Row::Note(t) if t == "grey"));
let Row::Buttons(copy, Some(stop)) = &rows[2] else {
panic!("the halves sit side by side: {:?}", rows.len());
};
assert_eq!((copy.w, stop.w), (150.0, 150.0));
assert!(matches!(&rows[3], Row::Buttons(help, None) if help.w == 310.0));
assert_eq!(rows.len(), 4);
let mut ui = ui;
ui.show(Page::Added(0));
press(&mut ui, &mut s, Act::PageRow(0, 3), false);
press(&mut ui, &mut s, Act::PageRow(0, 5), false);
let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
let stop = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 4),
..
}
)
})
.expect("Stop is drawn");
let atlas = super::super::tests::atlas();
ui.click(
&mut s,
&atlas,
stop.x + 1.0,
stop.y + 1.0,
(480.0, 240.0),
[480, 240],
false,
false,
);
assert_eq!(
*SHAPED.0.lock().unwrap(),
[3, 5],
"the inactive half heard nothing"
);
}
}
@@ -7,14 +7,6 @@ use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
fn fluid(fog: horizon_polytone::colors::FluidFog) -> pack_palette::FluidFog {
pack_palette::FluidFog {
color: fog.color,
radius: fog.radius,
fade: fog.fade,
}
}
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
@@ -37,10 +29,6 @@ pub fn read(root: &Path) -> pack_palette::PackColors {
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
fluid_fog: pack_palette::FluidFogs {
water: colors.fluid_fog.water.map(fluid),
lava: colors.fluid_fog.lava.map(fluid),
},
}
}
@@ -20,10 +20,6 @@ impl PackFormats for Formats {
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 _)
@@ -15,12 +15,6 @@ pub trait PackFormats: Send + Sync {
None
}
/// The name ending of the overlay's emissive textures (`stone_e.png` beside `stone.png`),
/// if its format has them.
fn emissive_suffix(&self, _root: &Path) -> Option<String> {
None
}
/// The 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
@@ -59,24 +59,6 @@ pub struct PackColors {
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
/// The fog the packs' fluid modifiers give water and lava.
pub fluid_fog: FluidFogs,
}
/// A fluid modifier's fog: a flat colour (RGB), and the fog's radius and fade multipliers (1 is
/// vanilla's).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FluidFog {
pub color: Option<u32>,
pub radius: f32,
pub fade: f32,
}
/// The fog the packs' fluid modifiers give water and lava.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct FluidFogs {
pub water: Option<FluidFog>,
pub lava: Option<FluidFog>,
}
impl Default for PackColors {
@@ -90,7 +72,6 @@ impl Default for PackColors {
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
fluid_fog: FluidFogs::default(),
}
}
}
@@ -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,13 +337,6 @@ 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
+7 -2
View File
@@ -51,7 +51,7 @@ pub fn enabled() -> bool {
}
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
/// what the render thread's slow frames spent their time in, for the opt-in performance
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
/// statistics (`play_session::Session::frame`). The thread takes them every frame ([`take`]).
pub fn record_here(on: bool) {
HERE.with(|h| h.set(on));
}
@@ -466,7 +466,7 @@ pub mod startup {
Mutex, OnceLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
time::{Duration, Instant},
};
static ON: AtomicBool = AtomicBool::new(false);
@@ -489,6 +489,11 @@ pub mod startup {
ON.store(sink.is_some(), Ordering::Relaxed);
}
/// The time since the process started ([`init`]'s origin; zero before it).
pub fn since_start() -> Duration {
ORIGIN.get().map_or(Duration::ZERO, Instant::elapsed)
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
+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"]
@@ -287,7 +287,6 @@
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
"Turn HDR on to use it.": "Turn HDR on to use it.",
"The shader pack lights the world itself.": "The shader pack lights the world itself.",
"Your GPU or graphics API doesn't support it.": "Your GPU or graphics API doesn't support it.",
"Not with software rendering, which runs it on the CPU.": "Not with software rendering, which runs it on the CPU.",
"Out of video memory: off until the next start.": "Out of video memory: off until the next start.",
+3 -1
View File
@@ -102,12 +102,15 @@ pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
#[cfg(feature = "horizon-extras")]
pub mod official_servers;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod play_session;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
@@ -169,7 +172,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;
+9 -5
View File
@@ -20,14 +20,18 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::options_ui::HORIZON_EXTRAS
.store(false, std::sync::atomic::Ordering::Relaxed);
}
horizon_client::telemetry::mark_start();
// The process's start, for the startup trace and the start's milestones.
horizon_client::spans::startup::init(std::time::Instant::now());
// 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`),
// then the Horizon parts still in this crate; none registers after this point.
game::player_hooks::register();
// Opt-in telemetry's play session plugs in through the hooks until it moves to a Horizon
// crate in horizon-official's parts (layer plan M5). horizon-extras: telemetry is Horizon's
// (docs/layers.md); without it no session is kept.
#[cfg(feature = "horizon-extras")]
let _ = horizon_hooks::register(&horizon_client::telemetry::HOOKS);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
+208 -41
View File
@@ -373,7 +373,6 @@ 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)]
@@ -403,7 +402,6 @@ mod remote_vehicles;
mod render_control;
mod render_settings;
mod render_tick;
mod replay;
mod repro;
mod resource_load;
mod resource_loader;
@@ -2803,7 +2801,7 @@ struct Game<'a> {
agent_last: Option<Instant>,
frame_stats: horizon_client::control::FrameStats,
/// This play session's frame and tick times, for opt-in performance statistics.
perf: horizon_client::telemetry::Session,
perf: horizon_client::play_session::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
/// `screenshot-sequence` while it runs.
@@ -2884,9 +2882,19 @@ struct Game<'a> {
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
/// service's presence status.
realm_session: bool,
/// Replay playback (game/replay.rs), and Horizon Studio's editor on it (game/studio.rs).
pub(super) replay: replay::Replay,
pub(super) studio: studio::Studio,
/// 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.
@@ -5018,7 +5026,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);
@@ -5837,7 +5845,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.
@@ -5934,7 +5942,8 @@ 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 show_hud = !self.ui.hide_gui && !self.replay.playing();
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
// rest of the HUD.
@@ -6273,7 +6282,23 @@ impl Game<'_> {
self.draw_sign_editor(&mut batch);
self.draw_book(&mut batch);
}
self.draw_studio_bars(&mut batch);
if !self.ui.hide_gui
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
self.studio_topbar.render(
&mut batch,
&self.gui.atlas,
self.cursor,
self.studio_freecam.locked_to_path,
);
self.studio_timeline.render(
&mut batch,
&self.gui.atlas,
self.cursor,
&self.studio_freecam,
);
}
if show_hud {
self.draw_pad_guide(&mut batch, guide_from);
}
@@ -6496,12 +6521,12 @@ impl Game<'_> {
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
if self.studio.flying() {
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.
@@ -6708,7 +6733,11 @@ 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 = self.own_player_alpha();
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()
};
self.player_model_geometry(camera, player_alpha, &mut out);
// A native pack's shadow pass alone gets the first-person model (no per-entity draws).
if self.pack_ids.is_none() && !self.own_body_in_view() {
@@ -7437,6 +7466,7 @@ impl Game<'_> {
self.spawn_phantoms();
self.tick_advancements();
self.tick_player_model();
self.tick_emotes();
Ok(())
}
@@ -9206,7 +9236,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.flying() {
if self.shows_panorama() || self.studio_freecam.active {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
@@ -9384,7 +9414,14 @@ 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());
self.studio_camera_path(&mut g);
if self.studio_freecam.active || self.studio_replay.is_some() {
horizon_client::studio::render_camera_path(
&self.studio_freecam.track,
&mut g,
None,
100,
);
}
lines.extend(g.lines);
let on_top = g.on_top;
pr.lap("pr_debug");
@@ -9406,7 +9443,7 @@ impl Game<'_> {
Vec::new()
};
pr.lap("pr_native_visuals");
let view = if self.studio.flying() {
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)
@@ -9532,7 +9569,7 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
let camera_effects = fov::camera_effects_enabled(title, self.studio.flying());
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
let bob = if !camera_effects {
glam::Mat4::IDENTITY
} else {
@@ -10037,7 +10074,7 @@ impl Game<'_> {
)
)
{
self.input_keyboard_used();
self.pad.keyboard_took_over = true;
}
if let WindowEvent::KeyboardInput { event, .. } = &event
&& event.state == ElementState::Pressed
@@ -10425,7 +10462,21 @@ impl Game<'_> {
let b = HudBatch::new([r.config.width, r.config.height]);
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.studio_mouse_dragged(b.width) {
if self.screen == Screen::Playing
&& (self.studio_timeline.dragging_playhead
|| self.studio_timeline.dragging_keyframe.is_some())
{
let action = self.studio_timeline.handle_mouse_drag(
self.cursor,
b.width,
&mut self.studio_freecam,
);
if matches!(
action,
horizon_client::studio::TimelineAction::KeyframeMoved
) {
self.studio_camera_track_dirty = true;
}
return Ok(());
}
if self.screen_mouse_dragged(previous) {
@@ -10476,7 +10527,7 @@ impl Game<'_> {
}
}
WindowEvent::MouseWheel { .. }
if self.screen == Screen::Playing && self.replay.playing() => {}
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)),
@@ -10753,7 +10804,90 @@ impl Game<'_> {
}
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
if self.studio_mouse_input(pressed, button) {
if !pressed && button == MouseButton::Left {
let action = self.studio_timeline.handle_mouse_up();
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
}
}
if (self.studio_freecam.active || self.studio_replay.is_some())
&& button == MouseButton::Right
{
if let Some(renderer) = &self.renderer {
let batch =
HudBatch::new([renderer.config.width, renderer.config.height]);
if self.studio_timeline.visible
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
{
if pressed
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
)
{
if index < self.studio_freecam.track.keyframes.len() {
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
}
self.studio_timeline.selected_keyframe = None;
}
return Ok(());
}
}
if pressed {
self.studio_topbar.close_dropdown();
self.studio_rmb_down = true;
self.capture(true);
return Ok(());
} else if self.studio_rmb_down {
self.studio_rmb_down = false;
self.capture(false);
return Ok(());
}
}
if pressed
&& button == MouseButton::Left
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
{
let size = self
.renderer
.as_ref()
.map(|renderer| [renderer.config.width, renderer.config.height])
.unwrap_or([1, 1]);
let batch = HudBatch::new(size);
let had_open_menu = self.studio_topbar.open_menu.is_some();
let topbar_action = self.studio_topbar.handle_mouse_click(
self.cursor,
batch.width,
batch.height,
);
if !matches!(topbar_action, horizon_client::studio::TopBarAction::None) {
self.handle_studio_topbar_action(topbar_action);
return Ok(());
}
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
return Ok(());
}
if self.studio_timeline.visible {
let action = self.studio_timeline.handle_mouse_down(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
);
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
return Ok(());
}
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
return Ok(());
}
}
}
if self.screen == Screen::Playing && self.studio_replay.is_some() {
return Ok(());
}
if matches!(self.screen, Screen::Inventory | Screen::Creative)
@@ -11223,7 +11357,24 @@ impl Game<'_> {
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
self.studio_fly(elapsed);
if self.studio_freecam.active
&& self.screen == Screen::Playing
&& !self.dialog_open()
{
let held = |key| f64::from(u8::from(self.keys.raw(&key)));
let forward = held(KeyCode::KeyW) - held(KeyCode::KeyS);
let strafe = held(KeyCode::KeyD) - held(KeyCode::KeyA);
let vertical =
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio_freecam.update_flight_motion(
forward,
strafe,
vertical,
fast,
elapsed.as_secs_f64().min(0.1),
);
}
// Arrow-key camera controls belong to gameplay; chat and
// other screens use the same keys for their own navigation.
if self.screen == Screen::Playing
@@ -11296,7 +11447,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();
@@ -11408,18 +11559,17 @@ impl Game<'_> {
server.set_legacy_rules(self.settings.extras.pvp.combat.rules);
}
self.clock.end_frame(self.pause);
let saved = self
.renderer
.as_mut()
.map(|r| std::mem::take(&mut r.saved_screenshots))
.unwrap_or_default();
for saved in saved {
self.finish_screenshot(saved);
}
let mut consumed = false;
let finished = self
.renderer
.as_mut()
.and_then(|r| r.screenshot_result.take());
if let Some(result) = &finished
&& self.ui.screenshot_message.is_some()
{
self.finish_screenshot(result);
consumed = true;
}
if let Some(result) = &finished
&& let Some(reply) = self.pending_screenshot.take()
{
@@ -11432,6 +11582,7 @@ impl Game<'_> {
}
.to_string(),
);
consumed = true;
}
if let Some(result) =
self.renderer.as_mut().and_then(|r| r.capture_result.take())
@@ -11440,6 +11591,12 @@ impl Game<'_> {
let value = result.unwrap_or_else(|e| serde_json::json!({ "error": e }));
let _ = reply.send(value.to_string());
}
// A result nobody asked for was the world icon's
// (`tick_world_icon`): a failure tries again a second
// later, as `tryTakeScreenshotIfNeeded` does.
if !consumed && let Some(Err(error)) = finished {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
if rendered {
self.frame_stats.record(elapsed.as_secs_f32() * 1000.0);
self.debug_charts.frame_times.log(elapsed.as_nanos() as i64);
@@ -12000,8 +12157,16 @@ impl<'a> Game<'a> {
entity_attachments: Default::default(),
remote: None,
realm_session: false,
replay: Default::default(),
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(),
@@ -12470,11 +12635,11 @@ pub fn run_with() -> Result<()> {
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
// A player who allows performance statistics also says what their slow frames spent their
// time in: this thread, which draws the frames, times its steps (wall time only).
// A hook that wants to know what slow frames spent their time in (a player who allows
// performance statistics): this thread, which draws the frames, times its steps (wall time
// only).
if options.execution_role.is_client()
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
.allows(horizon_telemetry::Kind::Perf)
&& horizon_hooks::engine::wants_frame_steps(&horizon_client::logging::game_home())
{
horizon_client::spans::record_here(true);
}
@@ -12500,8 +12665,10 @@ pub fn run_with() -> Result<()> {
// horizon-ctl drives: those start in a source checkout and wrote the list there.
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
let game_dir = horizon_client::logging::player_dir();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version, &game_dir));
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
// horizon-extras: the official server is Horizon's (docs/forking.md).
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
+29 -15
View File
@@ -848,20 +848,34 @@ impl Game<'_> {
self.armor_stands[i].last_hit = time;
return true;
}
// brokenByPlayer: the stand item, with the stand's name, pops at its
// block, then brokenByAnything: the loot table and the equipment.
let (width, height) = stand_box(&self.armor_stands[i]);
let p = self.armor_stands[i].position;
let pos = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let mut stand = ItemStack::new("minecraft:armor_stand", 1);
if let Some(name) = &self.armor_stands[i].custom_name {
stand.data_mut().custom_name =
Some(horizon_client::scoreboard::component_tag(&name.component()));
}
self.pop_resource(pos, stand);
// ArmorStand.kill(level, the causing entity), after the drops.
self.break_armor_stand_with_source(i, by);
// brokenByPlayer: the stand item, then brokenByAnything: the equipment.
let a = self.armor_stands.remove(i);
let (width, height) = stand_box(&a);
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.show_breaking_particles(at, f64::from(width), f64::from(height));
let center = [at[0], at[1] + if a.small { 0.4875 } else { 0.9875 }, at[2]];
let stand = ItemStack {
item: Arc::from("minecraft:armor_stand"),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(stand, center, [0.0; 3], 10);
for stack in a.equipment.into_iter().flatten() {
let r = &mut self.world.random;
let v = [
f64::from(r.next_float()) * 0.2 - 0.1,
0.2,
f64::from(r.next_float()) * 0.2 - 0.1,
];
self.spawn_item(stack, center, v, 10);
}
// ArmorStand.kill(level, the causing entity), after the drops.
self.stand_game_event("minecraft:entity_die", at, by);
self.world
.modified
.insert(((at[0].floor() as i32) >> 4, (at[2].floor() as i32) >> 4));
true
}
/// `Entity.gameEvent(event, attributedTo)` for a stand at `at`: the
@@ -1188,8 +1202,6 @@ impl Game<'_> {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let a_id = a.id;
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
// brokenByAnything's dropAllDeathLoot, before the equipment.
if !a.small && self.ui.rules.bool("mob_drops") {
self.armor_stand_death_loot(at, a.fire_ticks > 0);
@@ -1198,6 +1210,8 @@ impl Game<'_> {
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.entity_killed_event_by(at, source.or(Some(super::commands::Target::Stand(a_id))));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
@@ -42,25 +42,12 @@ impl Game<'_> {
self.trial_spawner_tick_positions.clear();
self.item_columns = Some(Default::default());
self.pass_mobs = Some(Default::default());
let tickers = self.world.block_entity_tickers();
let mut last_chunk = (i32::MIN, i32::MIN);
let mut last_chunk_ticking = false;
for slot in tickers.iter().copied() {
for slot in self.world.block_entity_tickers().iter().copied() {
let pos = slot.0;
let chunk = (pos.0 >> 4, pos.2 >> 4);
let chunk_ticking = if chunk == last_chunk {
last_chunk_ticking
} else {
last_chunk = chunk;
last_chunk_ticking = ticking(pos.0, pos.2);
last_chunk_ticking
};
if !chunk_ticking {
continue;
}
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
if self.world.block_entities.is_asleep(&pos)
|| !self.world.block_entities.is_live(slot)
|| !ticking(pos.0, pos.2)
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
{
continue;
@@ -223,14 +210,8 @@ impl Game<'_> {
/// (`ServerLevel.areEntitiesLoaded`).
pub(super) fn block_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
let levels = self.tickets.levels();
let has_pending = !self.world.entities_pending.is_empty();
let pending = has_pending.then(|| self.world.entities_pending.clone());
move |x, z| {
levels.block_ticking_at(x, z)
&& pending
.as_ref()
.is_none_or(|p| !p.contains(&(x >> 4, z >> 4)))
}
let pending = self.world.entities_pending.clone();
move |x, z| levels.block_ticking_at(x, z) && !pending.contains(&(x >> 4, z >> 4))
}
/// `DaylightDetectorBlock.tickEntity` of the detector at `pos`.
fn tick_daylight_detector(&mut self, pos: Pos) {
+10 -43
View File
@@ -255,8 +255,8 @@ pub struct UiState {
/// What this frame's HUD shows that Horizon's overlays keep clear of (`Game::hud_room`),
/// set by `hud_layers` before anything draws.
pub room: horizon_client::hud_kit::room::Room,
/// F2 screenshots not written yet: each reports in chat once its PNG is.
pub screenshot_messages: Vec<std::path::PathBuf>,
/// Path of an F2 screenshot waiting for its frame.
pub screenshot_message: Option<std::path::PathBuf>,
/// The options screens.
pub options: horizon_client::options_ui::OptionsUi,
/// The prepared resources before resource packs are layered on.
@@ -1982,17 +1982,15 @@ impl Game<'_> {
if std::fs::create_dir_all(&dir).is_err() {
return None;
}
// A screenshot still being written has no file yet: its name is taken all the same.
let name = screenshot_name(&dir, &self.ui.screenshot_messages);
let name = screenshot_name(&dir);
let renderer = self.renderer.as_mut()?;
let path = dir.join(name);
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
background: true,
});
self.ui.screenshot_messages.push(path.clone());
self.ui.screenshot_message = Some(path.clone());
Some(path)
}
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
@@ -2016,23 +2014,12 @@ impl Game<'_> {
self.take_screenshot();
true
}
/// A screenshot written on its thread: F2's reports in chat. Any other was the world
/// icon's (`tick_world_icon`): a failure tries again a second later, as
/// `tryTakeScreenshotIfNeeded` does.
pub(super) fn finish_screenshot(&mut self, saved: horizon_client::gfx::SavedScreenshot) {
let Some(at) = self
.ui
.screenshot_messages
.iter()
.position(|p| *p == saved.path)
else {
if let Err(error) = saved.result {
log::warn!("Horizon: couldn't save the world icon: {error}");
}
/// After a frame: report a finished F2 screenshot in chat.
pub(super) fn finish_screenshot(&mut self, result: &std::result::Result<[u32; 2], String>) {
let Some(path) = self.ui.screenshot_message.take() else {
return;
};
let path = self.ui.screenshot_messages.remove(at);
let message = match &saved.result {
let message = match result {
Ok(_) => {
log::info!("Saved screenshot to {}", path.display());
let name = path.file_name().unwrap_or_default().to_string_lossy();
@@ -2068,7 +2055,7 @@ fn chat_box_text(text: &str) -> String {
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
.collect()
}
pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) -> String {
pub fn screenshot_name(dir: &std::path::Path) -> String {
// Util.getFilenameFormattedDateTime: the local date and time.
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
let mut n = 1;
@@ -2078,8 +2065,7 @@ pub fn screenshot_name(dir: &std::path::Path, pending: &[std::path::PathBuf]) ->
} else {
format!("{base}_{n}.png")
};
let path = dir.join(&name);
if !path.exists() && !pending.contains(&path) {
if !dir.join(&name).exists() {
return name;
}
n += 1;
@@ -2121,25 +2107,6 @@ mod tests {
assert!(should_discard_draft(ChatExit::Done, true));
}
/// `Screenshot.getFile` picks the first free name of the second; an F2
/// screenshot still being written on its thread has no file yet, so a
/// second press in the same second takes the next name, not the same one.
#[test]
fn a_screenshot_being_written_keeps_its_name() {
let dir = std::env::temp_dir().join(format!("horizon-shots-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let first = screenshot_name(&dir, &[]);
let second = screenshot_name(&dir, &[dir.join(&first)]);
assert_ne!(first, second);
// Unless the clock turned to the next second in between.
let same_second = second.starts_with(first.trim_end_matches(".png"));
assert!(
!same_second || second == first.replace(".png", "_2.png"),
"{second}"
);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn resource_ids_are_listed_once_per_root() {
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
+6 -3
View File
@@ -3831,9 +3831,12 @@ impl Game<'_> {
return;
}
g.spawn = None;
g.respawn_config = Some(super::respawn_anchor::RespawnConfig::forced(
level, pos, yaw, pitch,
));
g.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: level,
pos,
yaw,
forced: true,
});
});
}
let dim = Text::literal(self.dim.id());
+2 -3
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,
@@ -1039,7 +1039,6 @@ impl Game<'_> {
path: path.trim().into(),
size: None,
scaled: false,
background: false,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
+285 -497
View File
@@ -20,17 +20,13 @@ use horizon_cosmetics::follow::Follow;
use horizon_cosmetics::prefs::{self, Others};
use horizon_cosmetics::trail::{Budget, Emitter};
use horizon_cosmetics::{Item, Slot};
use horizon_hooks::players::{
Badge, CameraOverride, CameraView, Frame, Gear, Kind, Mark, NameDecoration, PlayerModel,
PlayerModels, SpriteStore, Sprites,
};
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
const MARK: Mark = &horizon_cosmetics::mark::MARK;
pub(super) const MARK: horizon_client::tab_list::NameMark = &horizon_cosmetics::mark::MARK;
/// An item as the renderer takes it.
struct Baked {
@@ -59,8 +55,7 @@ impl Baked {
#[derive(Default)]
pub(super) struct Cosmetics {
/// The hooks' client (`Seat`), taken once it started.
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
client: Option<horizon_cosmetics::Cosmetics>,
/// By the item's address; each entry holds its item, so the address isn't reused.
baked: RefCell<FxHashMap<usize, Baked>>,
/// Items drawn whose texture isn't in the atlas yet.
@@ -70,6 +65,16 @@ pub(super) struct Cosmetics {
poses: RefCell<(Poses, Poses)>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// Companions, trails and auras.
fx: RefCell<Fx>,
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
@@ -400,13 +405,17 @@ fn slot_image(item: &Item) -> Vec<u8> {
/// Which slots an avatar's gear covers: a helmet or head item hides a hat, an elytra or a cape
/// what is worn on the back. A name tag style is never drawn on the body.
fn covered(gear: Gear, slot: Slot) -> bool {
fn covered(m: &Mob, slot: Slot) -> bool {
match slot {
Slot::Head => gear.head,
Slot::Back => gear.back,
Slot::Head => m.extra.head.is_some(),
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
Slot::Shoulder => false,
// A chestplate, not an elytra.
Slot::Torso => gear.chest,
Slot::Torso => m
.extra
.chest
.as_deref()
.is_some_and(|c| c != "minecraft:elytra"),
Slot::NameTag | Slot::Emote => true,
// On no bone: drawn by a pass of their own, never on the body.
Slot::Companion | Slot::Trail | Slot::Aura => true,
@@ -418,12 +427,12 @@ fn covered(gear: Gear, slot: Slot) -> bool {
/// (`worn` is None), another player's at "name tags only" or hidden, or every item `covered` (a
/// helmet over a hat). One max over at most five items, read when the item was parsed.
fn body_top(
others: Others,
extras: &horizon_client::extras::Extras,
own: bool,
worn: Option<&horizon_cosmetics::Worn>,
covered: impl Fn(Slot) -> bool,
) -> Option<f32> {
if !own && others != Others::Shown {
if !own && extras.effective_others() != Others::Shown {
return None;
}
worn?
@@ -440,424 +449,115 @@ pub(super) fn enabled() -> bool {
cfg!(feature = "horizon-extras") && horizon_cosmetics::enabled()
}
/// What the cosmetics' hooks keep for one splitscreen player (`horizon_hooks::seat`): the
/// service's client, the emote feed and what the account tells the service.
#[derive(Default)]
struct Seat {
client: Option<Arc<horizon_cosmetics::Cosmetics>>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`).
local: bool,
/// Show my cosmetics on its way to the service.
mine: AccountSync,
/// Show me as a Horizon player: sent at start, on each change and every hour while on.
listed: AccountSync,
/// Whether the signed-in account owns a cosmetic.
owner: OwnerCheck,
/// Which other players play an emote (`horizon_cosmetics::Feed`), started with the client.
feed: Option<horizon_cosmetics::Feed>,
/// My emote on its way to the service.
emote_out: EmoteOut,
/// What the game's settings and the player's emote say (`Game::cosmetics_say`).
said: Said,
/// Kept between ticks so a tick allocates nothing: the player models in view.
models: Vec<PlayerModel>,
/// The world is the window's own single-player one (`Frame::solo`).
solo: bool,
/// Name tag badges' sprites, by the item's address: their id (never reused) and the item,
/// held so the address isn't reused while it is there.
sprites: FxHashMap<usize, (u64, Arc<Item>)>,
last_sprite: u64,
/// Sprites drawing missed, put in the atlas next frame.
wanted: Vec<u64>,
}
/// What the game tells the hooks before each call, until the settings move here (spec #14091
/// step 5a) and the player's emote with them (step 4).
#[derive(Default)]
struct Said {
mine: bool,
listed: bool,
/// Other players' cosmetics are shown (not "name tags only", not hidden).
others_shown: bool,
/// Other players' cosmetics are hidden (not even name tag styles).
others_hidden: bool,
/// Horizon's mark by Horizon players' names.
marks: bool,
/// Cosmetics in single player: the signed-in account's on the player's own model.
solo: bool,
/// My emote pulled the camera back: when it started (the own model's age).
pull: Option<f32>,
/// My emote while it plays: its id and whether it loops.
playing: Option<(String, bool)>,
/// A folder's first emote, which mannequins play.
first: Option<Arc<Item>>,
}
/// One [`Seat`] per splitscreen player.
static SERVICES: Mutex<Vec<Seat>> = Mutex::new(Vec::new());
/// Runs `f` on this seat's [`Seat`]. Never called inside itself.
fn service<R>(f: impl FnOnce(&mut Seat) -> R) -> R {
let seat = horizon_hooks::seat();
let mut all = SERVICES.lock().unwrap_or_else(PoisonError::into_inner);
if all.len() <= seat {
all.resize_with(seat + 1, Seat::default);
}
f(&mut all[seat])
}
/// The cosmetics' hooks, registered when [`enabled`].
pub(super) struct Hooks;
pub(super) static HOOKS: Hooks = Hooks;
impl horizon_hooks::Hooks for Hooks {
fn frame(&self, frame: &Frame, sprites: &mut dyn SpriteStore) {
service(|s| {
s.solo = frame.solo;
s.frame();
s.put_sprites(sprites);
});
}
fn tick(&self, models: &mut dyn PlayerModels) {
service(|s| s.tick(models));
}
fn name_decoration(
&self,
model: &PlayerModel,
sprites: &dyn Sprites,
) -> Option<NameDecoration> {
service(|s| s.name_decoration(model, sprites))
}
fn player_mark(&self, uuid: u128) -> Option<Mark> {
service(|s| {
let client = s.client.as_ref().filter(|_| s.said.marks)?;
client.frame().horizon(uuid).then_some(MARK)
})
}
/// An emote pulls the camera back: it eases out over the emote's first quarter second.
fn camera(&self, own: &CameraView) -> Option<CameraOverride> {
let start = service(|s| s.said.pull)?;
let t = ((own.age - start) / 5.0).clamp(0.0, 1.0);
Some(CameraOverride::new(0.35 + 0.65 * t * t * (3.0 - 2.0 * t)))
}
}
impl Seat {
/// Once a frame: starts the service's client on first use, asks whether the account owns a
/// cosmetic, and tells the service what the account shows.
fn frame(&mut self) {
if self.client.is_none() {
let (client, local) = Cosmetics::client();
self.client = Some(Arc::new(client));
self.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
self.feed = Some(horizon_cosmetics::Feed::new(http()));
}
}
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if self.local {
self.owner.owns = true;
return;
}
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut self.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && self.mine.sending.is_none() && self.listed.sending.is_none()
{
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
let every = horizon_cosmetics::account::PRESENCE_EVERY;
self.mine.settle(None);
self.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy = self.owner.asking.is_some()
|| self.mine.sending.is_some()
|| self.listed.sending.is_some();
let said = &self.said;
if let Some(done) = self
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.emote_out.sending = None;
match done {
Ok(()) => self.emote_out.retry = None,
Err(_) => {
self.emote_out.retry = backoff(self.emote_out.retry);
self.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = said.playing.as_ref();
let looping = playing.is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id.as_str()).filter(|_| said.listed);
let o = &mut self.emote_out;
let changed = o.sent.as_ref().map(Option::as_deref) != Some(wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
let wish = wish.map(str::to_owned);
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if self.mine.due(said.mine) {
self.mine.start(said.mine, send_mine);
} else if self.listed.due(said.listed) {
self.listed.start(said.listed, send_listed);
}
}
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
fn name_decoration(
&mut self,
m: &PlayerModel,
sprites: &dyn Sprites,
) -> Option<NameDecoration> {
let said = &self.said;
let own = m.kind == Kind::Own;
let styles = if own { said.mine } else { !said.others_hidden };
if !(styles || said.marks) {
return None;
}
let uuid = match m.kind {
Kind::Own => own_uuid(m.uuid, self.solo, said.solo, || {
horizon_client::auth::current().map(|s| s.uuid)
}),
Kind::Mannequin if self.local => Some(u128::from(m.id)),
Kind::Player => m.uuid,
_ => None,
}?;
let mut frame = self.client.as_ref()?.frame();
let mark = (said.marks && frame.horizon(uuid)).then_some(MARK);
let worn = if styles { frame.worn(uuid) } else { None };
drop(frame);
let others = if said.others_shown {
Others::Shown
} else {
Others::NameTags
};
let top = body_top(others, own, worn.as_ref(), |slot| covered(m.gear, slot))
.filter(|_| !m.invisible);
let badge = worn.and_then(|w| self.badge(&w, sprites));
Some(NameDecoration::new(mark, badge, top))
}
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
/// until the badge is in the atlas (it is put there next frame).
fn badge(&mut self, worn: &horizon_cosmetics::Worn, sprites: &dyn Sprites) -> Option<Badge> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
let next = self.last_sprite + 1;
let (id, _) = *self
.sprites
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| (next, item.clone()));
self.last_sprite = self.last_sprite.max(id);
let Some([u0, v0, u1, v1]) = sprites.get(id) else {
if !self.wanted.contains(&id) {
self.wanted.push(id);
}
return None;
};
// The mark sits in the slot's top left corner.
let u1 = u0 + (u1 - u0) * item.texture.width as f32 / 64.0;
let v1 = v0 + (v1 - v0) * item.texture.height as f32 / 64.0;
Some(Badge::new(
[u0, v0, u1, v1],
u32::from_be_bytes([0, r, g, b]),
))
}
/// Puts the sprites drawing missed in the atlas; forgets those of items no one wears.
fn put_sprites(&mut self, store: &mut dyn SpriteStore) {
for id in self.wanted.drain(..) {
let item = self.sprites.values().find(|(i, _)| *i == id);
if let Some((_, item)) = item.filter(|_| !store.has(id)) {
store.put(id, slot_image(item));
}
}
self.sprites
.retain(|_, (_, item)| Arc::strong_count(item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
fn tick(&mut self, models: &mut dyn PlayerModels) {
let mut seen = std::mem::take(&mut self.models);
seen.clear();
models.for_each(&mut |m| {
if m.kind != Kind::Own {
seen.push(m);
}
});
self.play_others(models, &mut seen);
self.models = seen;
}
fn play_others(&self, models: &mut dyn PlayerModels, view: &mut Vec<PlayerModel>) {
let shown = self.said.others_shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.local && shown {
let Some(first) = &self.said.first else {
return;
};
for m in view.iter().filter(|m| m.kind == Kind::Mannequin) {
let age = m.age as f32;
let now = emotes::pose_playing(models.body_pose(m.id));
if now.is_none_or(|p| p.at(age).is_none()) {
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_model_playing(models, m.id, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.feed, &self.client) else {
return;
};
if !shown {
feed.ask([]);
for m in view.iter() {
emotes::set_model_playing(models, m.id, None);
}
return;
}
let mut frame = client.frame();
view.retain(|m| m.kind == Kind::Player && m.uuid.is_some_and(|u| frame.horizon(u)));
drop(frame);
feed.ask(view.iter().filter_map(|m| m.uuid));
let now = Instant::now();
for m in view.iter() {
let Some(uuid) = m.uuid else { continue };
let seen = feed.playing(uuid);
let age = m.age as f32;
let mut next = emotes::pose_playing(models.body_pose(m.id)).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if m.moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !m.moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_model_playing(models, m.id, next.filter(|p| p.at(age).is_some()));
}
}
}
impl Game<'_> {
/// Hands the hooks what the game's settings and the player's emote say (allocating only when
/// the emote changes).
pub(super) fn cosmetics_say(&mut self) {
if !enabled() {
return;
}
let first = if self.cosmetics.local {
service(|s| s.said.first.is_none())
.then(|| self.owned_emotes().first().cloned())
.flatten()
} else {
None
};
let extras = &self.settings.extras;
let playing = self.cosmetics.emotes.sending();
let pull = self.cosmetics.emotes.pull();
service(|s| {
let said = &mut s.said;
said.mine = extras.cosmetics_mine;
said.listed = extras.horizon_listed;
said.others_shown = extras.effective_others() == Others::Shown;
said.others_hidden = extras.effective_others() == Others::Hidden;
said.marks = extras.horizon_marks;
said.solo = extras.cosmetics_solo;
said.pull = pull;
if said.playing.as_ref().map(|(id, l)| (id.as_str(), *l)) != playing {
said.playing = playing.map(|(id, l)| (id.to_owned(), l));
}
if first.is_some() {
said.first = first;
}
});
}
/// Once a frame, after the hooks: takes the service's client for drawing and puts the
/// Once a frame, before drawing: starts the service's client on first use and puts the
/// textures the last frame wanted into the atlas.
pub(super) fn cosmetics_frame(&mut self) {
if !enabled() {
return;
}
let c = &mut self.cosmetics;
let owns = service(|s| {
if c.client.is_none() {
c.client = s.client.clone();
c.local = s.local;
if c.client.is_none() {
let (client, local) = Cosmetics::client();
c.client = Some(client);
c.local = local;
// Others' emotes come from the service only (a local folder has no one playing).
if !local {
c.feed = Some(horizon_cosmetics::Feed::new(http()));
}
s.owner.owns
});
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = owns;
}
c.frame.set(c.frame.get().wrapping_add(1));
c.fx.get_mut().frame(c.frame.get());
// Item files from a folder (the art, the bench): shown as to an owner, nothing asked.
if c.local {
c.owner.owns = true;
} else {
// Whether the account owns a cosmetic: what the last run learnt, then the service.
let o = &mut c.owner;
if !o.started {
o.started = true;
o.owns = account()
.is_some_and(|(_, uuid, home)| prefs::owned(&home, &uuid).unwrap_or(false));
}
if let Some(done) = o.asking.as_ref().and_then(|rx| rx.try_recv().ok()) {
o.asking = None;
match done {
Ok(owns) => (o.owns, o.known, o.retry) = (owns, true, None),
Err(_) => o.retry = backoff(o.retry),
}
}
// One sign-in at a time: Mojang keeps one joined server id per account.
let due = !o.known && o.retry.is_none_or(|(at, _)| Instant::now() >= at);
if due && o.asking.is_none() && c.mine.sending.is_none() && c.listed.sending.is_none() {
let (tx, rx) = channel();
o.asking = Some(rx);
std::thread::Builder::new()
.name("Cosmetics account".into())
.spawn(move || {
let _ = tx.send(check_owner());
})
.ok();
}
}
// Read every frame: the settings button shows it, and it is never saved.
self.settings.extras.cosmetics_owner = c.owner.owns;
// Show my cosmetics: sent once per change of the wish, then retried until confirmed.
// Show me as a Horizon player: also said again every hour while on.
if !c.local {
let every = horizon_cosmetics::account::PRESENCE_EVERY;
c.mine.settle(None);
c.listed.settle(Some(every));
// One sign-in at a time, as for the ownership check above.
let busy =
c.owner.asking.is_some() || c.mine.sending.is_some() || c.listed.sending.is_some();
let extras = &self.settings.extras;
if let Some(done) = c
.emote_out
.sending
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
c.emote_out.sending = None;
match done {
Ok(()) => c.emote_out.retry = None,
Err(_) => {
c.emote_out.retry = backoff(c.emote_out.retry);
c.emote_out.sent = None;
}
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = c.emotes.sending();
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
let o = &mut c.emote_out;
let changed = o.sent.as_ref() != Some(&wish);
let again = looping && o.again.is_some_and(|at| Instant::now() >= at);
let waited = o.retry.is_none_or(|(at, _)| Instant::now() >= at);
// Nothing sent yet and nothing playing: nothing to say (a failed send forgets what
// was sent: the service ends a one-shot by itself, a loop within a minute).
let quiet = wish.is_none() && o.sent.is_none();
let emote_due = !quiet && waited && (changed || again);
// One sign-in at a time, the emote's included.
if busy || o.sending.is_some() {
} else if emote_due {
o.sent = Some(wish.clone());
o.again = looping.then(|| Instant::now() + LOOP_AGAIN);
let (tx, rx) = channel();
o.sending = Some(rx);
let kept = o.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emote".into())
.spawn(move || {
let _ = tx.send(send_emote(&kept, wish.as_deref()));
})
.ok();
} else if c.mine.due(extras.cosmetics_mine) {
c.mine.start(extras.cosmetics_mine, send_mine);
} else if c.listed.due(extras.horizon_listed) {
c.listed.start(extras.horizon_listed, send_listed);
}
}
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
for item in uploads {
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
@@ -872,6 +572,88 @@ impl Game<'_> {
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// Once a tick: names the Horizon players in view to the emote feed and plays what it says
/// they play on their models; one that moved ends theirs here at once. Others' emotes show
/// with "Other players' cosmetics" on Shown only; otherwise nothing is asked.
pub(super) fn tick_others_emotes(&mut self) {
let shown = self.settings.extras.effective_others() == Others::Shown;
// Items from a folder (the art, the bench): every mannequin plays its first emote, again
// and again.
if self.cosmetics.local && shown {
let Some(first) = self.owned_emotes().first().cloned() else {
return;
};
for m in self.mobs.iter_mut() {
let age = m.age as f32;
if &*m.kind.id == "minecraft:mannequin"
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
{
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
};
emotes::set_playing(m, Some(playing));
}
}
return;
}
let (Some(feed), Some(client)) = (&self.cosmetics.feed, &self.cosmetics.client) else {
return;
};
if !shown {
feed.ask([]);
for m in self.mobs.iter_mut() {
emotes::set_playing(m, None);
}
return;
}
let mut frame = client.frame();
let mut horizon = Vec::new();
for m in self.mobs.iter() {
if &*m.kind.id == "minecraft:player"
&& let Some(uuid) = self.remote_player_uuid(m.id)
&& frame.horizon(uuid)
{
horizon.push((m.id, uuid));
}
}
drop(frame);
feed.ask(horizon.iter().map(|(_, u)| *u));
let now = Instant::now();
for (id, uuid) in horizon {
let seen = feed.playing(uuid);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
continue;
};
let age = m.age as f32;
let moved = {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
dx * dx + dz * dz > 1e-4
};
let mut next = emotes::playing(m).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if moved && p.stop.is_none() {
p.stop = Some(age);
}
}
(Some(seen), slot) if !moved => {
let ticks = now.duration_since(seen.started).as_secs_f32() * 20.0;
*slot = Some(horizon_cosmetics::Playing {
item: seen.item,
start: age - ticks,
stop: None,
});
}
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
}
}
/// Asks the service which emotes the account owns, for the wheel: when it opens, at most
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
pub(super) fn refresh_owned_emotes(&mut self) {
@@ -887,7 +669,7 @@ impl Game<'_> {
}
let (tx, rx) = channel();
e.loading = Some(rx);
let kept = service(|s| s.emote_out.kept.clone());
let kept = self.cosmetics.emote_out.kept.clone();
std::thread::Builder::new()
.name("Cosmetics emotes owned".into())
.spawn(move || {
@@ -896,6 +678,81 @@ impl Game<'_> {
.ok();
}
/// Items from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins are dressed and marked too.
pub(super) fn cosmetics_local(&self) -> bool {
self.cosmetics.local
}
/// What goes by `m`'s name: Horizon's mark if they play Horizon (and the mark is on), the
/// name tag style they wear if cosmetics are shown for them (name tags only counts), and how
/// high what is drawn on their body gets (`body_top`). One look in the cache for all three.
pub(super) fn cosmetics_marks(&self, m: &Mob, own: bool) -> super::names::Marks {
let mut marks = super::names::Marks::default();
let extras = &self.settings.extras;
let styles = if own {
extras.cosmetics_mine
} else {
extras.effective_others() != Others::Hidden
};
if !(styles || extras.horizon_marks) {
return marks;
}
let (Some(client), Some(uuid)) = (&self.cosmetics.client, self.cosmetics_uuid(m, own))
else {
return marks;
};
let mut frame = client.frame();
marks.horizon = extras.horizon_marks && frame.horizon(uuid);
let worn = if styles { frame.worn(uuid) } else { None };
drop(frame);
if !horizon_client::mobs::trader::is_invisible(m) {
marks.top = body_top(extras, own, worn.as_ref(), |slot| covered(m, slot));
}
marks.style = worn.and_then(|w| self.cosmetics_tag(&w));
marks
}
/// Those of `uuids` who play Horizon, with the mark on (the player list): one look each.
pub(super) fn horizon_players(
&self,
uuids: impl Iterator<Item = u128>,
) -> std::collections::HashSet<u128> {
match &self.cosmetics.client {
Some(c) if self.settings.extras.horizon_marks => {
let mut frame = c.frame();
uuids.filter(|u| frame.horizon(*u)).collect()
}
_ => Default::default(),
}
}
/// The name tag style in `worn`: its badge's place in the atlas and its line's colour. None
/// until the badge is in the atlas.
fn cosmetics_tag(&self, worn: &horizon_cosmetics::Worn) -> Option<super::names::TagStyle> {
let item = worn.iter().find(|i| i.slot == Slot::NameTag)?;
let [r, g, b] = item.tag?.line;
let frame = self.cosmetics.frame.get();
let mut baked = self.cosmetics.baked.borrow_mut();
let entry = baked
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| Baked::new(item, frame));
let Some(slot) = self.gui.atlas.sprites.get(&entry.location) else {
self.cosmetics.uploads.borrow_mut().push(item.clone());
return None;
};
if entry.marked.replace(frame) != frame {
self.skin_downloads.borrow_mut().drawn(&entry.location);
}
// The mark sits in the slot's top left corner.
let mut mark = *slot;
mark.u1 = mark.u0 + (slot.u1 - slot.u0) * item.texture.width as f32 / 64.0;
mark.v1 = mark.v0 + (slot.v1 - slot.v0) * item.texture.height as f32 / 64.0;
Some(super::names::TagStyle {
mark,
line: u32::from_be_bytes([0, r, g, b]),
})
}
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
let client = self.cosmetics.client.as_ref()?;
@@ -987,7 +844,7 @@ impl Game<'_> {
}
continue;
}
if emoting.is_none() && covered(super::player_hooks::gear(m), item.slot) {
if emoting.is_none() && covered(m, item.slot) {
continue;
}
let Some((models, uv)) = self.cosmetics_baked(item) else {
@@ -1105,7 +962,7 @@ impl Game<'_> {
mod tests {
use super::*;
use horizon_client::extras::Extras;
use horizon_client::mobs::anim::BodyPose;
use std::sync::Mutex;
/// A name tag style with a 1×1 mark.
const TAG: &str = r##"{"format": 1, "id": "tag", "slot": "name_tag", "parts": [],
@@ -1293,18 +1150,13 @@ mod tests {
let (short, long) = (Duration::from_millis(300), Duration::from_secs(10));
let top_of = |extras: &Extras, patience| {
let (worn, _) = other_player_from(Service(Arc::default(), both), extras, patience);
body_top(extras.effective_others(), false, worn.as_ref(), |_| false)
body_top(extras, false, worn.as_ref(), |_| false)
};
// No cosmetics drawn on the body (a name tag style only): vanilla's height exactly.
let (worn, _) = other_player(&shown, long);
assert!(worn.is_some());
assert_eq!(
tag(body_top(
shown.effective_others(),
false,
worn.as_ref(),
|_| false
)),
tag(body_top(&shown, false, worn.as_ref(), |_| false)),
VANILLA
);
// A small hat under the tag: vanilla's height too.
@@ -1318,9 +1170,7 @@ mod tests {
);
// A helmet over it: vanilla's height.
let (worn, _) = other_player_from(Service(Arc::default(), both), &shown, long);
let helmet = body_top(shown.effective_others(), false, worn.as_ref(), |slot| {
slot == Slot::Head
});
let helmet = body_top(&shown, false, worn.as_ref(), |slot| slot == Slot::Head);
assert_eq!(tag(helmet), VANILLA);
// Others' cosmetics on name tags only, or hidden: nothing on the body, vanilla's height.
for others in [Others::NameTags, Others::Hidden] {
@@ -1336,66 +1186,4 @@ mod tests {
assert_eq!(tag(top_of(&extras, patience)), VANILLA, "{others:?}");
}
}
/// Player models as the hooks see them, with the pose laid over each.
struct View(Vec<(PlayerModel, Option<Arc<dyn BodyPose>>)>);
impl PlayerModels for View {
fn connection(&self) -> u64 {
1
}
fn for_each(&self, f: &mut dyn FnMut(PlayerModel)) {
self.0.iter().for_each(|(m, _)| f(*m));
}
fn body_pose(&self, id: u32) -> Option<&Arc<dyn BodyPose>> {
self.0.iter().find(|(m, _)| m.id == id)?.1.as_ref()
}
fn set_body_pose(&mut self, id: u32, pose: Option<Arc<dyn BodyPose>>) {
if let Some((_, p)) = self.0.iter_mut().find(|(m, _)| m.id == id) {
*p = pose;
}
}
}
#[test]
fn with_a_folder_mannequins_play_its_first_emote_out_of_step_and_players_do_not() {
let item = Arc::new(
Item::parse(
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
"emote": {"length": 20, "blend": 0, "tracks": [
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]}]}}"#,
)
.unwrap(),
);
let mut service = Seat {
local: true,
said: Said {
others_shown: true,
first: Some(item.clone()),
..Said::default()
},
..Seat::default()
};
let model = |id, kind, uuid| (PlayerModel::new(id, kind, uuid, 50, false), None);
let mut view = View(vec![
model(1, Kind::Own, None),
model(41, Kind::Mannequin, None),
model(2, Kind::Player, Some(7)),
]);
service.tick(&mut view);
let playing = emotes::pose_playing(view.0[1].1.as_ref()).expect("the mannequin plays");
assert!(Arc::ptr_eq(&playing.item, &item));
// Out of step by its id: 50 - 41 % 40.
assert_eq!(playing.start, 49.0);
assert!(view.0[0].1.is_none() && view.0[2].1.is_none());
// Still playing: the same pose stays.
let first = view.0[1].1.clone().unwrap();
service.tick(&mut view);
assert!(Arc::ptr_eq(&first, view.0[1].1.as_ref().unwrap()));
// Others' cosmetics not shown: a new mannequin plays nothing.
service.said.others_shown = false;
view.0.push(model(3, Kind::Mannequin, None));
service.tick(&mut view);
assert!(view.0[3].1.is_none());
}
}
@@ -10,7 +10,6 @@ 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.
@@ -38,15 +37,10 @@ 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<(&str, bool)> {
pub(in crate::game) fn sending(&self) -> Option<(String, 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, 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)
Some((p.item.id.clone(), looping))
}
}
@@ -65,48 +59,23 @@ impl BodyPose for EmotePose {
}
}
/// 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?;
/// 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()?;
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
pose_playing(m.emote.as_ref())
}
/// 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) {
/// 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) {
(None, None) => true,
(Some(a), Some(b)) => {
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
}
_ => false,
};
(!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);
if !same {
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
}
}
@@ -260,7 +229,7 @@ impl Game<'_> {
}
}
/// Once a tick: my emote (the others' are `cosmetics::Seat::tick`'s, a hook's).
/// Once a tick: my emote, then the other players'.
pub(in crate::game) fn tick_emotes(&mut self) {
if let Some(done) = self
.cosmetics
@@ -281,6 +250,20 @@ 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)
}
}
@@ -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,18 +280,25 @@ 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);
}
// 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.
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& horizon_hooks::engine::any()
&& super::super::cosmetics::enabled()
&& (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.player_layers_geometry(mob, base, camera, alpha as f32, packed, gpu, out)
{
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(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))),
@@ -353,7 +353,7 @@ impl Game<'_> {
let found =
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
used.push(match found {
Some((p, yaw, _pitch)) => format!(
Some((p, yaw)) => format!(
"respawn {} {} {} {}",
num(p.x),
num(p.y),
@@ -579,7 +579,6 @@ impl Game<'_> {
dim: self.dim,
pos: [p(0), p(1), p(2)],
yaw: 0.0,
pitch: 0.0,
forced: false,
});
}
+1 -2
View File
@@ -739,8 +739,7 @@ impl Game<'_> {
// anchor puts the player, facing it; else onto the spawn column.
let mut target = target;
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
// This existing owner transition adapter carries yaw only; its pitch path remains open.
if let Some((at, yaw, _pitch)) = self.find_respawn_and_use(c, false)? {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
return Ok(Some((at, Some(yaw))));
}
target = fallback_spawn(self.spawn, self.level.world_spawn);
+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();
@@ -70,10 +70,9 @@ fn overworld_spawnpoint_is_validated_at_both_player_blocks() {
assert_eq!(actual.is_some(), allowed, "{block} at height {height}");
assert_eq!(game.respawn_config.is_some(), allowed);
assert_eq!(game.level.world_spawn, Some([0, 80, 0]));
if let Some((at, yaw, pitch)) = actual {
if let Some((at, yaw)) = actual {
assert_eq!(at, Vec3::new(5.5, 64.1, 5.5));
assert_eq!(yaw, 0.0);
assert_eq!(pitch.to_bits(), 0.0f32.to_bits());
}
}
}
+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.flying() {
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.flying(),
self.studio_freecam.active,
self.ui.hands_drawn(
self.spectator(),
self.sleeping.is_some(),
@@ -4190,8 +4190,8 @@ impl Game<'_> {
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
});
let at = found.map_or(world, |(at, yaw, pitch)| {
(host.hub.guests[i].yaw, host.hub.guests[i].pitch) = (yaw, pitch);
let at = found.map_or(world, |(at, yaw)| {
host.hub.guests[i].yaw = yaw;
[at.x, at.y, at.z]
});
if found.is_none() {
+72 -171
View File
@@ -118,78 +118,34 @@ impl View {
}
None
}
/// `WorldlyContainer.getSlotsForFace` or every slot, zero-allocation iterator.
pub(super) fn slots_for_face_iter(&self, face: Option<Direction>) -> Slots<'_> {
/// `WorldlyContainer.getSlotsForFace` or every slot.
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
match (self.kind, face) {
(Kind::Furnace, Some(Direction::Down)) => Slots::Slice(&[2, 1]),
(Kind::Furnace, Some(Direction::Up)) => Slots::Slice(&[0]),
(Kind::Furnace, Some(_)) => Slots::Slice(&[1]),
(Kind::Brewing, Some(Direction::Up)) => Slots::Slice(&[3]),
(Kind::Brewing, Some(Direction::Down)) => Slots::Slice(&[0, 1, 2, 3]),
(Kind::Brewing, Some(_)) => Slots::Slice(&[0, 1, 2, 4]),
(Kind::Composter(8), Some(Direction::Down)) => Slots::Slice(&[0]),
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => Slots::Slice(&[0]),
(Kind::Composter(_), _) => Slots::Slice(&[]),
(Kind::Furnace, Some(Direction::Down)) => vec![2, 1],
(Kind::Furnace, Some(Direction::Up)) => vec![0],
(Kind::Furnace, Some(_)) => vec![1],
(Kind::Brewing, Some(Direction::Up)) => vec![3],
(Kind::Brewing, Some(Direction::Down)) => vec![0, 1, 2, 3],
(Kind::Brewing, Some(_)) => vec![0, 1, 2, 4],
(Kind::Composter(8), Some(Direction::Down)) => vec![0],
(Kind::Composter(level), Some(Direction::Up)) if level < 7 => vec![0],
(Kind::Composter(_), _) => vec![],
(Kind::Mod, Some(face)) => self
.container
.as_ref()
.map_or(Slots::Slice(&[]), |c| Slots::Mod(&c.faces[face as usize])),
_ => Slots::Range(0..self.len()),
.map_or_else(Vec::new, |c| c.faces[face as usize].clone()),
_ => (0..self.len()).collect(),
}
}
/// The slots `HopperBlockEntity.addItem` tries through `face`, zero-allocation iterator.
pub(super) fn slots_to_add_iter(&self, face: Option<Direction>) -> Slots<'_> {
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
self.slots_for_face_iter(face)
} else {
Slots::Range(0..self.len())
}
}
/// `WorldlyContainer.getSlotsForFace` or every slot.
fn slots_for_face(&self, face: Option<Direction>) -> Vec<usize> {
self.slots_for_face_iter(face).collect()
}
/// The slots `HopperBlockEntity.addItem` tries through `face`.
fn slots_to_add(&self, face: Option<Direction>) -> Vec<usize> {
self.slots_to_add_iter(face).collect()
}
}
pub(super) enum Slots<'a> {
Range(std::ops::Range<usize>),
Slice(&'static [usize]),
Mod(&'a [usize]),
}
impl Iterator for Slots<'_> {
type Item = usize;
#[inline]
fn next(&mut self) -> Option<usize> {
match self {
Slots::Range(r) => r.next(),
Slots::Slice(s) => {
let (&first, rest) = s.split_first()?;
*s = rest;
Some(first)
}
Slots::Mod(s) => {
let (&first, rest) = s.split_first()?;
*s = rest;
Some(first)
}
if matches!(self.kind, Kind::Furnace | Kind::Brewing | Kind::Mod) && face.is_some() {
self.slots_for_face(face)
} else {
(0..self.len()).collect()
}
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = match self {
Slots::Range(r) => r.len(),
Slots::Slice(s) => s.len(),
Slots::Mod(s) => s.len(),
};
(len, Some(len))
}
}
impl ExactSizeIterator for Slots<'_> {}
pub(super) fn dir_from_name(name: &str) -> Option<Direction> {
Some(match name {
@@ -224,6 +180,8 @@ struct Containers<'a> {
registry: &'static horizon_world::registry::BlockRegistry,
/// Brewing recipes, for what a brewing stand accepts (not needed for signals).
brewing: Option<&'a horizon_client::brewing::Brewing>,
/// The stack of a full composter's `OutputContainer`.
bone_meal: ItemStack,
}
impl Containers<'_> {
fn prop(&self, state: BlockStateId, key: &str) -> Option<&str> {
@@ -388,7 +346,7 @@ impl Containers<'_> {
fn get(&self, view: &View, slot: usize) -> Option<&ItemStack> {
if let Kind::Composter(level) = view.kind {
// OutputContainer holds one bone meal; InputContainer composts at once.
return (level == 8 && slot == 0).then_some(bone_meal_stack());
return (level == 8 && slot == 0).then_some(&self.bone_meal);
}
let (pos, i) = view.locate(slot)?;
self.world
@@ -494,7 +452,7 @@ impl Containers<'_> {
}
/// `HopperBlockEntity.isFullContainer`.
fn is_full(&self, view: &View, face: Direction) -> bool {
view.slots_for_face_iter(Some(face)).all(|i| {
view.slots_for_face(Some(face)).into_iter().all(|i| {
self.insertion_item(view, i)
.is_some_and(|s| s.count >= self.max_stack(&s))
})
@@ -540,9 +498,6 @@ impl ItemColumns {
x: i32,
z: i32,
) -> bool {
if items.is_empty() {
return false;
}
let (len, last) = (items.len(), items.last().map(|i| i.id));
let stale = self
.seen
@@ -601,14 +556,13 @@ impl StampedItemColumns {
}
}
fn bone_meal_stack() -> &'static ItemStack {
static STACK: std::sync::LazyLock<ItemStack> = std::sync::LazyLock::new(|| ItemStack {
fn bone_meal() -> ItemStack {
ItemStack {
item: "minecraft:bone_meal".into(),
count: 1,
damage: 0,
extra: None,
});
&STACK
}
}
/// Comparator input from a container block entity (0 when there is none).
@@ -625,6 +579,7 @@ pub(super) fn container_signal(
items,
registry,
brewing: None,
bone_meal: bone_meal(),
};
c.view((pos.x, pos.y, pos.z), true)
.filter(|v| !matches!(v.kind, Kind::Composter(_)))
@@ -680,6 +635,7 @@ impl Game<'_> {
items: self.items(),
registry: self.pack.assets.registry,
brewing: Some(&self.stations().brewing),
bone_meal: bone_meal(),
}
}
pub(super) fn block_can_place(&self, pos: [i32; 3], slot: usize, stack: &ItemStack) -> bool {
@@ -891,7 +847,7 @@ impl Game<'_> {
return self.composter_container_insert([x, y, z], stack, face == Some(Direction::Up));
}
let mut rest = Some(stack);
for slot in view.slots_to_add_iter(face) {
for slot in view.slots_to_add(face) {
let Some(stack) = rest.take() else { break };
rest = self.try_move_in_item(from_hopper, view, stack, slot, face);
}
@@ -924,31 +880,32 @@ impl Game<'_> {
/// is still a hopper (`BlockEntityType.isValid`).
pub(super) fn tick_hopper(&mut self, pos: Pos) {
let time = self.world.game_time;
// Every hopper keeps its cooldown and the tick it last ticked
// (`HopperBlockEntity.tickedGameTime`), idle ones too: a hopper that
// receives an item reads it for its cooldown (8, or 7 once it ticked).
let state = self.hoppers.entry(pos).or_default();
state.cooldown = state.cooldown.wrapping_sub(1);
state.ticked = time;
if state.cooldown > 0 {
let waiting = {
let state = self.hoppers.entry(pos).or_default();
state.cooldown = state.cooldown.wrapping_sub(1);
state.ticked = time;
state.cooldown > 0
};
if waiting {
return;
}
state.cooldown = 0;
// Most ticks only advance the cooldown: the block is checked when the
// hopper is about to move, not on every tick it waits.
let state = self.world.block(pos.0, pos.1, pos.2);
let Some(info) = self.pack.assets.registry.state(state) else {
self.hoppers.remove(&pos);
return;
};
if info.name != "minecraft:hopper" {
let hopper = self
.res
.facts()
.get(self.world.block(pos.0, pos.1, pos.2))
.name
.as_ref()
== "minecraft:hopper";
if !hopper {
self.hoppers.remove(&pos);
return;
}
if info.properties.get("enabled").is_some_and(|v| v == "false") {
return;
if let Some(state) = self.hoppers.get_mut(&pos) {
state.cooldown = 0;
}
self.hopper_try_move_items_with_state(pos, state);
self.hopper_try_move_items(pos);
}
/// Drops the cooldowns of hoppers whose block entity is gone.
pub(super) fn forget_gone_hoppers(&mut self) {
@@ -960,69 +917,23 @@ impl Game<'_> {
/// `HopperBlockEntity.tryMoveItems` with `suckInItems`.
pub(super) fn hopper_try_move_items(&mut self, pos: Pos) {
let state = self.world.block(pos.0, pos.1, pos.2);
self.hopper_try_move_items_with_state(pos, state);
}
fn hopper_try_move_items_with_state(&mut self, pos: Pos, state: BlockStateId) {
let Some(info) = self.pack.assets.registry.state(state) else {
let c = self.containers();
let enabled = c.prop(state, "enabled") != Some("false");
let Some(view) = c.view(pos, false) else {
return;
};
if info.properties.get("enabled").is_some_and(|v| v == "false") {
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
return;
}
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
return;
}
let Some(BlockEntity::Chest(items)) = self.world.block_entities.get(&pos) else {
return;
};
if items.len() != HOPPER_SLOTS {
return;
}
let is_empty = items
.iter()
.all(|s| s.as_ref().is_none_or(|s| s.count == 0));
let has_empty_slot = items
.iter()
.any(|s| s.as_ref().is_none_or(|s| s.count == 0));
let above = (pos.0, pos.1 + 1, pos.2);
// Fast path for empty hopper with no source container and no item entities:
// if empty, cannot eject; if no container above, cannot suck container;
// if no dropped items nearby, cannot suck entities -> nothing can happen!
if is_empty
&& self.world.block_entities.get(&above).is_none()
&& self.carts.is_empty()
&& self.boats.is_empty()
&& (self.dropped.is_empty()
|| self
.item_columns
.as_mut()
.is_some_and(|c| !c.may_reach(&self.dropped, pos.0, pos.2)))
&& self
.pack
.assets
.registry
.state(self.world.block(above.0, above.1, above.2))
.is_none_or(|s| s.name != "minecraft:composter")
{
return;
}
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
let view = View {
parts: vec![(pos, HOPPER_SLOTS)],
kind: Kind::Hopper,
container: None,
};
let mut changed = false;
if !is_empty {
changed = self.hopper_eject_with_state(pos, state, &view);
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &view);
}
let is_full = !changed && !has_empty_slot && {
let c = self.containers();
(0..HOPPER_SLOTS).all(|i| c.get(&view, i).is_some_and(|s| s.count >= c.max_stack(s)))
};
if !is_full {
let c = self.containers();
let full =
(0..view.len()).all(|i| c.get(&view, i).is_some_and(|s| s.count == c.max_stack(s)));
if !full {
changed |= self.hopper_suck(pos, &view);
}
if changed {
@@ -1084,20 +995,19 @@ impl Game<'_> {
/// `tryMoveItems(…, () -> addItem(hopper, itemEntity))`; true when the
/// item entity was taken whole.
fn hopper_take_item(&mut self, pos: Pos, i: usize) -> bool {
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) {
return false;
}
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
if c.prop(state, "enabled") == Some("false") {
return false;
}
let Some(view) = c.view(pos, false) else {
return false;
};
if c.prop(state, "enabled") == Some("false")
|| self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0)
{
return false;
}
let mut changed = false;
if !c.is_empty(&view) {
changed = self.hopper_eject_with_state(pos, state, &view);
changed = self.hopper_eject(pos, &view);
}
let c = self.containers();
let full =
@@ -1133,7 +1043,8 @@ impl Game<'_> {
}
/// `HopperBlockEntity.ejectItems`: into the block container in front, else
/// a container entity there (`getContainerAt`).
fn hopper_eject_with_state(&mut self, pos: Pos, state: BlockStateId, hopper: &View) -> bool {
fn hopper_eject(&mut self, pos: Pos, hopper: &View) -> bool {
let state = self.world.block(pos.0, pos.1, pos.2);
let c = self.containers();
let Some(facing) = c.prop(state, "facing").and_then(dir_from_name) else {
return false;
@@ -1245,9 +1156,6 @@ impl Game<'_> {
if blocked {
return false;
}
if self.dropped.is_empty() {
return false;
}
// Hopper.SUCK_AABB: the hopper bowl and the block above.
let (min, max) = (
[
@@ -1306,7 +1214,7 @@ impl Game<'_> {
// second setChanged after the move or, when the hopper has no
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = bone_meal_stack().clone();
let bone_meal = c.bone_meal.clone();
if !horizon_client::mod_api::mods().is_empty() {
let mut cancel = false;
self.native_inventory_move_item(
@@ -1336,15 +1244,13 @@ impl Game<'_> {
source: &View,
hopper: &View,
) -> bool {
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
{
let c = self.containers();
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
for slot in source.slots_for_face(Some(Direction::Down)) {
let c = self.containers();
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
if self.hopper_move_canceled(source, slot, source, hopper) {
continue;
@@ -1363,9 +1269,7 @@ impl Game<'_> {
}
return true;
}
Some(rest) => {
self.put_back(source, slot, rest);
}
Some(rest) => self.put_back(source, slot, rest),
}
}
false
@@ -1375,9 +1279,6 @@ impl Game<'_> {
/// minecarts, chest boats) whose box meets the 1x1x1 box around `at`,
/// picked at random.
fn entity_container_at(&mut self, at: [f64; 3]) -> Option<u32> {
if self.carts.is_empty() && self.boats.is_empty() {
return None;
}
let meets = |b: horizon_core::geometry::Aabb| {
let (lo, hi) = (b.min_position(), b.max_position());
lo.x < at[0] + 0.5
+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);
+9 -6
View File
@@ -300,7 +300,11 @@ impl Game<'_> {
) {
continue;
}
let marks = self.hooks_name_marks(m, Some(m.id) == own);
let marks = if player || self.cosmetics_local() {
self.cosmetics_marks(m, Some(m.id) == own)
} else {
Marks::default()
};
let at = match marks.top {
Some(top) => p + glam::DVec3::Y * cosmetic_lift(top, m.model_scale()),
None => p,
@@ -766,10 +770,10 @@ impl Game<'_> {
if background { BACKGROUND } else { 0 },
),
};
if let Some(mark) = marks.mark.filter(|_| n == last) {
if n == last && marks.horizon {
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 mark {
for &(mx, my, w, h, argb) in super::cosmetics::MARK {
let a = text_alpha * (argb >> 24) / 255;
batch.fill(
&self.gui.atlas,
@@ -933,11 +937,10 @@ struct NameTag {
marks: Marks,
}
/// 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.
/// What Horizon draws by a player's name: its mark (they play Horizon) and their name tag style.
#[derive(Clone, Copy, Default)]
pub(super) struct Marks {
pub mark: Option<horizon_hooks::players::Mark>,
pub horizon: bool,
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`).
@@ -471,7 +471,6 @@ pub(crate) mod tests {
dim: game.dim,
pos: [1, 2, 3],
yaw: 0.0,
pitch: 0.0,
forced: false,
};
let spawn = Vec3::new(1.0, 2.0, 3.0);
@@ -46,8 +46,6 @@ pub(super) struct SpawnScratch {
/// dx/dz order, that no earlier player's square holds.
segments: Vec<Vec<(i32, i32)>>,
stale: Vec<bool>,
/// Reusable buffer for spawn players across ticks to avoid heap allocation.
pub(super) spawn_players: Vec<SpawnPlayer>,
}
impl SpawnScratch {
fn chunk(p: &LevelPlayer) -> (i32, i32) {
@@ -130,20 +128,6 @@ impl SpawnScratch {
self.prepare(players);
self.candidates.clear();
let mut count = 0;
if players.len() == 1 {
let p = &players[0];
count = self.segments.first().map_or(0, |s| s.len());
if !p.spectator
&& let Some(segment) = self.segments.first()
{
for &c in segment {
if close_enough_for_spawning(p, c) && keep(c) {
self.candidates.push(c);
}
}
}
return count as i32;
}
for (i, segment) in self.segments.iter().enumerate() {
count += segment.len();
let near = &self.neighbours[i];
@@ -666,10 +650,8 @@ fn spawn_cost_types(biomes: &horizon_client::biome::Biomes) -> Vec<&str> {
types
}
impl<'a> Game<'a> {
pub(super) fn natural_level_with_players(
&self,
players: Vec<LevelPlayer>,
) -> GameLevel<'_, 'a> {
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
let players = self.level_players();
let nearest = players
.iter()
.filter(|p| !p.spectator)
@@ -688,9 +670,6 @@ impl<'a> Game<'a> {
costed: spawn_cost_types(world_biomes!(self)),
}
}
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
self.natural_level_with_players(self.level_players())
}
/// Creates a mob the spawner accepted, finalized the game's way; the
/// caller adds it with [`Game::add_spawned`].
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
@@ -794,8 +773,6 @@ impl<'a> Game<'a> {
scratch: &mut SpawnScratch,
) -> i32 {
let levels = self.tickets.levels();
// Recomputed each pass: a chunk's ticket level and entity loading change without the
// players moving or the chunk count changing.
scratch.collect(players, |(cx, cz)| {
levels.loaded_ticking(cx, cz) && super::ticking_chunk(&self.world, cx, cz)
})
@@ -842,28 +819,31 @@ impl<'a> Game<'a> {
{
let level = self.natural_level();
laps.lap("sn_level");
let counted = self.mobs.iter().map(|m| Counted {
entity: &m.kind.id,
category: Category::from_name(&m.kind.category),
block: [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent
|| horizon_client::mobs::ai::requires_custom_persistence(m),
});
scratch.spawn_players.clear();
scratch
.spawn_players
.extend(players.iter().map(|p| SpawnPlayer {
let counted: Vec<Counted> = self
.mobs
.iter()
.map(|m| Counted {
entity: &m.kind.id,
category: Category::from_name(&m.kind.category),
block: [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent
|| horizon_client::mobs::ai::requires_custom_persistence(m),
})
.collect();
let players = players
.iter()
.map(|p| SpawnPlayer {
position: [p.position.x, p.position.y, p.position.z],
spectator: p.spectator,
}));
let spawn_players = std::mem::take(&mut scratch.spawn_players);
})
.collect();
laps.lap("sn_materialize");
let mut state = SpawnState::new(&level, spawnable, spawn_players, counted, |cx, cz| {
let mut state = SpawnState::new(&level, spawnable, players, &counted, |cx, cz| {
self.world.chunks.contains_key(&(cx, cz))
});
laps.lap("sn_state");
@@ -874,8 +854,7 @@ impl<'a> Game<'a> {
Vec::new()
};
laps.lap("sn_categories");
// Util.shuffle, every pass: it draws from the level's random even when no
// category may spawn.
// Util.shuffle.
for i in (1..scratch.candidates.len()).rev() {
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
scratch.candidates.swap(i, j);
@@ -906,7 +885,6 @@ impl<'a> Game<'a> {
);
}
}
scratch.spawn_players = state.into_players();
}
spawn_selection::attempt_counters(true);
laps.lap("sn_attempts");
+1 -1
View File
@@ -185,7 +185,7 @@ impl Game<'_> {
let output = self
.renderer
.as_ref()
.map_or(Default::default(), |r| r.output());
.map_or(Default::default(), |r| *r.output());
ui.draw(&self.settings, output, batch, &self.gui.atlas, self.cursor);
self.ui.options = ui;
self.show_calibration((batch.width, batch.height));
+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;
+9 -10
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.flying() {
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;
// A hook may pull the camera back (an emote, from the player's face).
let pull = self.hooks_camera_distance(partial);
// An emote pulls the camera back from the player's face (`game/cosmetics/emotes.rs`).
let pull = self.emote_camera_pull(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.flying() {
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.flying()
!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).
+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.replay.playing() {
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.flying() {
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};
+39 -5
View File
@@ -10,8 +10,41 @@ 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.replay.playing() {
self.studio_key(key);
if self.screen == Screen::Playing && self.studio_replay.is_some() {
match key {
KeyCode::Escape => {
self.close_studio_replay();
return Ok(());
}
KeyCode::KeyP => {
self.pause_focus = None;
self.screen = Screen::Paused;
self.pause_menu = true;
self.capture(false);
}
KeyCode::KeyF => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::ToggleFreecam,
),
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;
}
KeyCode::KeyK => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::AddCameraKeyframe,
),
KeyCode::Delete => {
if let Some(index) = self.studio_timeline.selected_keyframe {
self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
);
}
}
_ => {}
}
return Ok(());
}
// `CreativeModeInventoryScreen.keyPressed` clears `ignoreTextInput` on
@@ -45,7 +78,7 @@ impl Game<'_> {
KeyCode::Escape => match self.screen {
Screen::Options => self.options_key(KeyCode::Escape, None)?,
Screen::Playing => {
if self.replay.playing() && self.captured {
if self.studio_replay.is_some() && self.captured {
self.capture(false);
return Ok(());
}
@@ -180,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.replay.playing() => {
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
self.cycle_camera()
}
KeyCode::KeyE
@@ -246,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
@@ -1,448 +0,0 @@
//! 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, Mesh, PartPoses, Pass, PlayerDraw, PlayerModel,
PlayerModels, SpriteStore, Sprites,
};
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 super::cosmetics::enabled()
&& let Err(e) = horizon_hooks::register(&super::cosmetics::HOOKS)
{
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()
})
}
/// 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 in the world pass, 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,
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)
}
}
impl PlayerDraw for DrawPlayer<'_, '_> {
fn pass(&self) -> Pass {
self.pass
}
fn ticks(&self) -> f32 {
self.m.age as f32 + self.alpha
}
fn bone(&mut self, bone: Bone) -> Option<[[f32; 4]; 4]> {
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],
uv: [f32; 4],
pose: &mut dyn FnMut(&mut dyn PartPoses),
) {
let model = part_of(mesh);
let matrix = glam::Mat4::from_cols_array_2d(&matrix);
self.poses.reset(&model);
pose(&mut PosesOf(&mut self.poses));
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: self.light,
overlay: 0,
};
if skin.draw(&model, matrix, &self.poses, paint) {
self.bounds = skin.bounds;
return;
}
}
let poses = &self.poses;
horizon_client::mobs::render_model(
&model,
matrix,
&|p: &Part| poses.pose(p),
uv,
self.light,
0,
self.out,
);
}
}
impl Game<'_> {
/// What crates draw on player model `m` in the world pass (`creatures.rs`), then the
/// cosmetics still drawn here: 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)?;
let mut bounds = None;
if engine::any() {
let model = model(m, kind, self.remote_player_uuid(m.id));
let mut draw = DrawPlayer {
game: self,
m,
pass: Pass::World,
base,
alpha,
light,
gpu,
out: &mut *out,
bounds: None,
body: None,
poses: Poses::pooled(),
};
engine::player_layers(&model, &mut draw);
bounds = draw.bounds;
}
let worn = super::cosmetics::enabled()
.then(|| self.cosmetics_geometry(m, false, base, Some(camera), alpha, light, gpu, out))
.flatten();
match (bounds, worn) {
(Some((l, h)), Some((lo, hi))) => Some((l.min(lo), h.max(hi))),
(b, w) => b.or(w),
}
}
/// 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();
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.flying());
&& (self.player.is_some() || self.studio_freecam.active);
let active = self.focused || self.agent_pointer;
if let Some((xo, yo)) = self
.mouse
@@ -75,7 +75,10 @@ impl Game<'_> {
.is_some_and(|t| t.elapsed() > Duration::from_millis(500))
&& self.control.is_none()
&& self.screen == Screen::Playing
&& !studio_suppresses_focus_pause(self.replay.playing(), self.studio.flying())
&& !studio_suppresses_focus_pause(
self.studio_replay.is_some(),
self.studio_freecam.active,
)
&& self.player.is_some()
&& !self.options.no_pause
&& self.settings.file.bool("pauseOnLostFocus")
@@ -146,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;
@@ -350,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_up() {
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;
}
+54 -29
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.replay.playing() {
if self.studio_replay.is_some() {
return true;
}
self.remote
@@ -3110,6 +3110,33 @@ impl Game<'_> {
index.get(mobs, id as u32).map(mob_tracking_body)
});
}
fn remove_entity(&mut self, id: i32) {
self.detach_remote_tracking_entity(id);
self.audio.remove_sound_entity(id as u32);
// Entity.setRemoved detaches every passenger, even when no empty
// SetPassengers packet preceded the vehicle's removal. Otherwise
// mobs and other players keep their passenger pose after it is gone.
self.remote_set_passengers(id, &[]);
self.mobs.retain(|m| m.id != id as u32);
self.orbs.retain(|o| o.id != id as u32);
self.dropped.retain(|d| d.id != id as u32);
// Before the crystals go: the dragon's beam index follows them.
self.remove_remote_dragon(id);
self.remove_remote_object(id as u32);
self.remove_remote_projectile(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.passenger_vehicles.remove(&(id as u32));
r.passenger_vehicles.retain(|_, v| *v != id as u32);
r.lerps.remove(&id);
r.cart_lerps.remove(&id);
r.stepped.remove(&id);
r.head_lerps.remove(&id);
r.pending_items.remove(&id);
}
}
/// Map a menu slot to Horizon's inventory screen slot ids.
pub(super) fn menu_slot(menu: &str, slot: i32) -> Option<usize> {
let s = usize::try_from(slot).ok()?;
@@ -3680,7 +3707,7 @@ impl Game<'_> {
self.move_remote_interaction(p.id, p.position);
}
C::RemoveEntities(p) => {
for &id in &p.ids {
for id in p.ids {
if let Some(r) = self.remote.as_mut() {
if let Some(uuid) = r.level_players.remove(&id) {
r.attachments.remove(&Holder::Player(uuid));
@@ -3689,8 +3716,8 @@ impl Game<'_> {
r.level_player_textures.remove(&id);
r.attachments.remove(&Holder::Entity(id as u32));
}
self.remove_entity(id);
}
self.remove_entities(&p.ids);
}
C::MoveEntityPos(play::MoveEntityPos(m))
| C::MoveEntityPosRot(play::MoveEntityPosRot(m))
@@ -6149,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;
@@ -6158,7 +6185,7 @@ impl Game<'_> {
self.apply_remote_abilities();
}
}
if !self.replay.playing() {
if self.studio_replay.is_none() {
self.previous_position = position;
}
self.yaw = yaw;
@@ -6414,7 +6441,7 @@ impl Game<'_> {
}
return Ok(());
}
let stick = self.input_move();
let stick = self.pad_move();
if self.player.is_none() {
return Ok(());
}
@@ -6494,7 +6521,7 @@ impl Game<'_> {
return Ok(());
};
player.fluids.boat = boat;
if !riding && !self.replay.playing() {
if !riding && self.studio_replay.is_none() {
self.previous_position = player.position;
}
let before = (
@@ -6701,10 +6728,10 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
// The player's own model (third person, the inventory's preview).
if !self.replay.playing() {
if self.studio_replay.is_none() {
self.tick_player_model();
}
self.client_hooks_tick();
self.tick_emotes();
if !dead {
self.remote_tick_using();
}
@@ -7242,15 +7269,15 @@ impl Game<'_> {
} else if m.alive() {
m.body_rotation_tick();
}
// Squid.aiStep; WalkAnimationState.update from the horizontal distance.
// Squid.aiStep follows LivingEntity's interpolation and head turn.
horizon_client::mobs::aquatic::squid_client_step(m, &mut mob_fx);
let d = (m.position.x - m.previous.x).hypot(m.position.z - m.previous.z) as f32;
let (target, factor) = if m.species() == horizon_client::mobs::Species::Frog {
(horizon_client::mobs::frog::walk_target(m, d), 0.4)
} else if horizon_client::mobs::camel::is_camel(m.species()) {
horizon_client::mobs::camel::walk_target(m, d)
// WalkAnimationState.update from the horizontal distance moved.
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
let (target, factor) = if horizon_client::mobs::camel::is_camel(m.species()) {
// Camel.updateWalkAnimation.
horizon_client::mobs::camel::walk_target(m, (dx * dx + dz * dz).sqrt() as f32)
} else {
((d * 4.0).min(1.0), 0.4)
((((dx * dx + dz * dz).sqrt() * 4.0) as f32).min(1.0), 0.4)
};
m.limb_speed += (target - m.limb_speed) * factor;
m.limb_position += m.limb_speed;
@@ -9498,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
@@ -10501,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.replay.playing()
self.studio_replay.is_none()
&& self
.remote
.as_ref()
@@ -10585,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.replay.playing() {
if self.studio_replay.is_some() {
return false;
}
use horizon_client::level_loading::Reason;
@@ -10687,11 +10714,17 @@ 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> {
self.remote.as_ref()?.player_uuid(id)
let r = self.remote.as_ref()?;
let uuid = *r.level_players.get(&(id as i32))?;
(uuid != r.profile).then_some(uuid)
}
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
r.level_player_name(id as i32, &r.player_uuid(id)?)
let uuid = r.level_players.get(&(id as i32))?;
if *uuid == r.profile {
return None;
}
r.level_player_name(id as i32, uuid)
}
/// Status for `horizon-ctl status`.
/// The other players' entities in the level and their names (their
@@ -11472,11 +11505,6 @@ 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
@@ -11614,9 +11642,6 @@ fn in_storage_range(view_distance: i32, center: (i32, i32), x: i32, z: i32) -> b
x.abs_diff(center.0) <= range && z.abs_diff(center.1) <= range
}
// A packet's removals (`RemoveEntities`), apart: this file may not grow.
#[path = "remote_removal.rs"]
mod removal;
#[cfg(test)]
mod tests {
#[test]
@@ -793,17 +793,13 @@ impl Game<'_> {
}
pub(super) fn remove_remote_projectile(&mut self, id: u32) {
self.fangs.list.retain(|f| f.id != id);
self.tnts.retain(|t| t.id != id);
self.falling.retain(|f| f.id != id);
self.crystals.retain(|c| c.id != id);
self.remove_remote_small_projectiles(id);
}
pub(super) fn remove_remote_small_projectiles(&mut self, id: u32) {
self.fangs.list.retain(|f| f.id != id);
self.creatures.projectiles.retain(|p| p.id != id);
self.tridents.retain(|t| t.id != id);
self.rockets.retain(|r| r.id != id);
self.crystals.retain(|c| c.id != id);
self.potions.remove_remote_thrown(id);
self.potions.clouds.retain(|c| c.id != id);
self.end.remove_remote_eye(id);
@@ -1,142 +0,0 @@
//! Removing entities a server dropped (`RemoveEntities`), out of remote.rs,
//! which may not grow.
use super::super::Game;
impl Game<'_> {
pub(super) fn remove_entity(&mut self, id: i32) {
self.remove_entities(&[id]);
}
/// `ClientboundRemoveEntitiesPacket`: a packet's ids go together, the big
/// entity lists filtered once (#13007), each id's own cleanup in order.
pub(super) fn remove_entities(&mut self, ids: &[i32]) {
for &id in ids {
self.detach_remote_tracking_entity(id);
self.audio.remove_sound_entity(id as u32);
// Entity.setRemoved detaches every passenger, even when no empty
// SetPassengers packet preceded the vehicle's removal. Otherwise
// mobs and other players keep their passenger pose after it is gone.
self.remote_set_passengers(id, &[]);
// Before the crystals go: the dragon's beam index follows them.
self.remove_remote_dragon(id);
self.crystals.retain(|c| c.id != id as u32);
self.remove_remote_object(id as u32);
self.remove_remote_small_projectiles(id as u32);
self.persist.uuids.remove(&(id as u32));
if let Some(r) = self.remote.as_mut() {
r.glowing.remove(&id);
r.entities.remove(&id);
r.passenger_vehicles.remove(&(id as u32));
r.lerps.remove(&id);
r.cart_lerps.remove(&id);
r.stepped.remove(&id);
r.head_lerps.remove(&id);
r.pending_items.remove(&id);
}
}
let removed: horizon_client::fast_hash::FxHashSet<u32> = if ids.len() > 1 {
ids.iter().map(|&id| id as u32).collect()
} else {
Default::default()
};
let keep = |id: u32| {
if ids.len() > 1 {
!removed.contains(&id)
} else {
ids.first().is_none_or(|&removed| id != removed as u32)
}
};
self.mobs.retain(|m| keep(m.id));
self.orbs.retain(|o| keep(o.id));
self.dropped.retain(|d| keep(d.id));
self.falling.retain(|f| keep(f.id));
self.tnts.retain(|t| keep(t.id));
if let Some(r) = self.remote.as_mut() {
r.passenger_vehicles.retain(|_, v| keep(*v));
}
}
}
#[cfg(test)]
mod tests {
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn remove_entities_filters_mixed_entity_lists() {
use crate::game::{
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
};
use horizon_protocol::packets::play::{self, Clientbound as C};
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = ExecutionRole::Client;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (client, _server) = horizon_protocol::connection::Pipe::pair();
game.connect_local(client, "RemovalTest").unwrap();
for (id, kind) in [
(40, "minecraft:falling_block"),
(41, "minecraft:falling_block"),
(42, "minecraft:falling_block"),
(43, "minecraft:tnt"),
(44, "minecraft:tnt"),
(45, "minecraft:experience_orb"),
(46, "minecraft:experience_orb"),
(47, "minecraft:zombie"),
(48, "minecraft:zombie"),
] {
if matches!(kind, "minecraft:falling_block" | "minecraft:tnt") {
assert!(game.spawn_remote_projectile(
id,
kind,
[0.0, 64.0, 0.0],
(0.0, 0.0),
[0.0; 3],
0
));
} else {
game.spawn_entity(id, kind, [0.0, 64.0, 0.0], [0.0; 3]);
}
}
for id in [49, 50] {
game.dropped.push(horizon_client::entities::ItemEntity::new(
id,
horizon_client::items::ItemStack::new("minecraft:sand", 1),
horizon_core::geometry::Vec3::new(0.0, 64.0, 0.0),
horizon_core::geometry::Vec3::ZERO,
0.0,
));
}
game.handle_packet(C::RemoveEntities(play::RemoveEntities {
ids: vec![40, 42, 43, 45, 47, 49],
}))
.unwrap();
assert_eq!(game.falling.iter().map(|e| e.id).collect::<Vec<_>>(), [41]);
assert_eq!(game.tnts.iter().map(|e| e.id).collect::<Vec<_>>(), [44]);
assert_eq!(game.orbs.iter().map(|e| e.id).collect::<Vec<_>>(), [46]);
assert_eq!(game.mobs.iter().map(|e| e.id).collect::<Vec<_>>(), [48]);
assert_eq!(game.dropped.iter().map(|e| e.id).collect::<Vec<_>>(), [50]);
}
}
@@ -168,7 +168,6 @@ impl Game<'_> {
full_water_vision: self.settings.extras.pvp.tweaks.clear_water,
night_vision: self.fog_night_vision(),
environment: self.pack.polytone_colors.env,
fluid_fog: self.pack.polytone_colors.fluid_fog,
};
let mut fog = horizon_client::fog::setup_fog(tables, &self.fog, &input);
if !self.settings.horizon.fog {
-855
View File
@@ -1,855 +0,0 @@
//! Recording a session's packets as `.mcpr` and playing them back in a `Game`: the `/studio record`
//! and `/studio replay` commands (chat and `horizon-ctl`), the replay's clock and its world. This is
//! platform (docs/layers.md, "Replays"); Studio's editor (`studio.rs`) builds on it.
use super::{Game, PendingWorldEntry, Screen};
use horizon_client::{
Result,
studio::{Metadata, ReplayDriver},
text::Text,
};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
/// The packet replay a `Game` plays, if any, and its playback clock.
#[derive(Default)]
pub(super) struct Replay {
/// The packet replay playing, if any.
pub(super) driver: Option<ReplayDriver>,
/// Camera perspective to restore after leaving replay playback.
pub(super) previous_camera_type: Option<super::perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
pub(super) standalone: bool,
pub(super) last_tick: Option<std::time::Instant>,
pub(super) virtual_tick_ms: f32,
}
impl Replay {
/// A replay plays: the world shown is the recording's.
pub(super) fn playing(&self) -> bool {
self.driver.is_some()
}
/// How far the replay's player is between its last two ticks, while a replay plays: it
/// moves on the replay's clock, not the game's.
pub(super) fn player_alpha(&self) -> Option<f64> {
self.playing()
.then(|| (f64::from(self.virtual_tick_ms) / 50.0).clamp(0.0, 1.0))
}
}
impl Game<'_> {
/// How far the window's own player model is between two ticks: on the replay's clock while a
/// replay plays, else the game's.
pub(super) fn own_player_alpha(&self) -> f64 {
self.replay
.player_alpha()
.unwrap_or_else(|| self.clock.camera_fraction())
}
/// In-game chat command handler: `/studio <record|replay> ...`.
/// Returns true if the command was recognized and handled.
pub(super) fn studio_command(&mut self, command: &str) -> bool {
let Some(rest) = command.strip_prefix("studio") else {
return false;
};
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return false;
}
let words: Vec<&str> = rest.split_whitespace().collect();
if words.is_empty() {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
return true;
}
match words[0] {
"freecam" | "keyframe" | "path" if super::studio::EDITOR => {
return self.studio_editor_command(&words);
}
"record" => match words.get(1).copied() {
Some("start") => match self.start_studio_recording(words.get(2).copied()) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.started", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("stop") => match self.stop_studio_recording() {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let path = v["path"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.saved", &[&path]));
}
Ok(v) => {
let error = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
Err(error) => {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
}
},
Some("status") => {
let status = self.studio_recording_status();
if status["active"].as_bool() == Some(true) {
let elapsed =
(status["elapsed_ms"].as_u64().unwrap_or(0) / 1000).to_string();
let packets = status["packet_count"].as_u64().unwrap_or(0).to_string();
let size =
(status["bytes_written"].as_u64().unwrap_or(0) / 1024).to_string();
let path = status["path"].as_str().unwrap_or_default();
self.system_message(Text::tr(
"horizon.studio.record.status.active",
&[&elapsed, &packets, &size, &path],
));
} else if let Some(error) = status["error"].as_str() {
self.system_message(Text::tr("horizon.studio.record.error", &[&error]));
} else {
self.system_message(Text::translate(
"horizon.studio.record.status.inactive",
&[],
));
}
}
_ => self.system_message(Text::translate("horizon.studio.record.usage", &[])),
},
"replay" => match words.get(1).copied() {
Some("open") => {
let Some(path) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.open.usage",
&[],
));
return true;
};
match self.open_studio_replay(path) {
Ok(v) if v["ok"].as_bool() == Some(true) => {
let duration_s = v["duration_ms"].as_f64().unwrap_or(0.0) / 1000.0;
let packets = v["packet_count"].as_u64().unwrap_or(0);
let duration = format!("{duration_s:.1}");
let packets = packets.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.opened",
&[path, &duration, &packets],
));
}
Ok(v) => {
let err = v["error"].as_str().unwrap_or_default();
self.system_message(Text::tr(
"horizon.studio.replay.open_error",
&[&err],
));
}
Err(e) => {
let error = e.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.open_error",
&[&error],
));
}
}
}
Some("pause") => {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(true);
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("resume") => {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(false);
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("speed") => {
let Some(spd_str) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.speed.usage",
&[],
));
return true;
};
if let Ok(spd) = spd_str.parse::<f64>() {
let new_speed = if let Some(r) = self.replay.driver.as_mut() {
r.set_speed(spd);
Some(r.speed())
} else {
None
};
if let Some(speed) = new_speed {
let speed = format!("{speed:.2}");
self.system_message(Text::tr(
"horizon.studio.replay.speed.set",
&[&speed],
));
} else {
self.system_message(Text::translate(
"horizon.studio.replay.inactive",
&[],
));
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.speed.invalid",
&[],
));
}
}
Some("seek") => {
let Some(ms_str) = words.get(2) else {
self.system_message(Text::translate(
"horizon.studio.replay.seek.usage",
&[],
));
return true;
};
if let Ok(ms) = ms_str.parse::<u32>() {
if self.replay.driver.is_some() {
match self.seek_studio_replay(ms) {
Ok(()) => {
let actual_ms = self
.replay
.driver
.as_ref()
.map_or(ms, |replay| ms.min(replay.duration_ms()));
let ms_text = actual_ms.to_string();
let seconds = format!("{:.1}", actual_ms as f32 / 1000.0);
self.system_message(Text::tr(
"horizon.studio.replay.seeked",
&[&ms_text, &seconds],
));
}
Err(e) => {
if e.kind() == std::io::ErrorKind::Unsupported {
self.system_message(Text::translate(
"horizon.studio.replay.seek.backward_requires_standalone",
&[],
));
} else {
let error = e.to_string();
self.system_message(Text::tr(
"horizon.studio.replay.seek.error",
&[&error],
));
}
}
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.inactive",
&[],
));
}
} else {
self.system_message(Text::translate(
"horizon.studio.replay.seek.invalid",
&[],
));
}
}
Some("status") => {
if let Some(r) = self.replay.driver.as_ref() {
let position = r.position_ms().to_string();
let duration = r.duration_ms().to_string();
let progress = format!("{:.1}", r.progress() * 100.0);
let speed = format!("{:.2}", r.speed());
let paused = r.is_paused().to_string();
let finished = r.is_finished().to_string();
self.system_message(Text::tr(
"horizon.studio.replay.status",
&[&position, &duration, &progress, &speed, &paused, &finished],
));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
Some("close") => {
if self.close_studio_replay() {
self.system_message(Text::translate("horizon.studio.replay.closed", &[]));
} else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
}
}
_ => {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
}
},
_ => {
self.system_message(Text::translate("horizon.studio.replay.usage", &[]));
}
}
true
}
/// Reset playback tick accumulator and align positions on seek.
pub(super) fn reset_studio_playback_ticks(&mut self) {
self.replay.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() {
m.previous = player.position;
m.position = player.position;
m.limb_speed = 0.0;
m.previous_limb_speed = 0.0;
}
}
}
/// Reconstruct replay state at a target timestamp. Standalone replays can
/// safely reset their synthetic remote; an in-world replay can only seek
/// 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
.replay
.driver
.as_ref()
.map_or(0, ReplayDriver::position_ms);
let packets = if self.replay.standalone {
let packets = self
.replay
.driver
.as_mut()
.expect("replay checked by caller")
.seek_and_collect(target_ms)?;
self.init_remote_for_replay()
.map_err(|error| std::io::Error::other(error.to_string()))?;
packets
} else {
if target_ms < current_ms {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"backward seek requires a standalone replay",
));
}
self.replay
.driver
.as_mut()
.expect("replay checked by caller")
.seek_forward(target_ms)?
};
self.dispatch_studio_replay_packets(packets);
self.reset_studio_playback_ticks();
self.replay.last_tick = Some(std::time::Instant::now());
Ok(())
}
fn dispatch_studio_replay_packets(
&mut self,
packets: Vec<horizon_client::studio::mcpr::TimestampedPacket>,
) {
for pkt in packets {
match horizon_protocol::packets::play::Clientbound::decode(pkt.id, &pkt.payload) {
Ok(packet) => {
let _ = self.handle_packet(packet);
}
Err(e) => {
let name = horizon_protocol::ids::name(
horizon_protocol::State::Play,
horizon_protocol::Direction::Clientbound,
pkt.id,
)
.unwrap_or("?");
log::debug!(
"Studio replay skipped packet {name} (id {:#x}): {e}",
pkt.id
);
}
}
}
}
/// 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.replay.driver.as_mut() else {
self.replay.last_tick = None;
return Ok(());
};
let now = std::time::Instant::now();
let delta = match self.replay.last_tick {
Some(last) => now
.saturating_duration_since(last)
.min(Duration::from_millis(250)),
None => Duration::from_millis(16),
};
self.replay.last_tick = Some(now);
let ready_packets = match driver.advance(delta) {
Ok(pkts) => pkts,
Err(e) => {
log::warn!("Studio replay advance error: {e}");
return Ok(());
}
};
self.dispatch_studio_replay_packets(ready_packets);
let paused = self
.replay
.driver
.as_ref()
.is_some_and(|replay| replay.is_paused());
if self.player_model.is_none() {
self.tick_player_model();
}
if !paused {
let speed = self
.replay
.driver
.as_ref()
.map_or(1.0, |r| r.speed() as f32)
.max(0.1);
self.replay.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.replay.virtual_tick_ms >= 50.0 {
self.replay.virtual_tick_ms -= 50.0;
if let Some(player) = &self.player {
let current_pos = player.position;
let (dx, dz) = (
current_pos.x - self.previous_position.x,
current_pos.z - self.previous_position.z,
);
let walked = (dx * dx + dz * dz).sqrt() as f32;
if let Some(m) = self.player_model.as_mut() {
m.previous = self.previous_position;
m.position = current_pos;
m.previous_limb_speed = m.limb_speed;
let target_speed = (walked * 4.0).min(1.0);
m.limb_speed += (target_speed - m.limb_speed) * 0.4;
if m.limb_speed < 0.001 {
m.limb_speed = 0.0;
}
m.limb_position += m.limb_speed;
m.previous_yaw = m.yaw;
m.previous_head_yaw = m.head_yaw;
m.previous_head_pitch = m.head_pitch;
m.previous_body_yaw = m.body_yaw;
m.yaw = self.yaw;
m.head_yaw = self.yaw;
m.head_pitch = self.pitch;
let mut target = m.body_yaw;
if dx * dx + dz * dz > 0.0025 {
let walk = (dz.atan2(dx) as f32).to_degrees() - 90.0;
let facing =
((self.yaw - walk + 180.0).rem_euclid(360.0) - 180.0).abs();
target = if 95.0 < facing && facing < 265.0 {
walk - 180.0
} else {
walk
};
}
let diff = (target - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
m.body_yaw += diff * 0.3;
let body_diff = (self.yaw - m.body_yaw + 180.0).rem_euclid(360.0) - 180.0;
if body_diff.abs() > 50.0 {
m.body_yaw += body_diff - body_diff.signum() * 50.0;
}
m.age = m.age.wrapping_add(1);
}
self.previous_position = current_pos;
}
}
} else if let Some(m) = self.player_model.as_mut() {
m.previous_limb_speed = m.limb_speed;
}
self.studio_replay_advanced();
Ok(())
}
/// Control socket command handler for `studio`.
pub(super) fn studio_control(&mut self, words: &[&str]) -> Result<Option<Value>> {
if words.first().copied() != Some("studio") {
return Ok(None);
}
match words.get(1).copied() {
Some("record") => match words.get(2).copied() {
Some("start") => {
let name = words.get(3).copied();
let res = self.start_studio_recording(name)?;
Ok(Some(res))
}
Some("stop") => {
let res = self.stop_studio_recording()?;
Ok(Some(res))
}
Some("status") => {
let res = self.studio_recording_status();
Ok(Some(res))
}
_ => Ok(Some(json!({ "error": "unknown studio record subcommand" }))),
},
Some("replay") => match words.get(2).copied() {
Some("open") => {
let path = words.get(3).ok_or("missing path for studio replay open")?;
let res = self.open_studio_replay(path)?;
Ok(Some(res))
}
Some("pause") => {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(true);
Ok(Some(json!({ "ok": true, "paused": true })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("resume") => {
if let Some(r) = self.replay.driver.as_mut() {
r.set_paused(false);
Ok(Some(json!({ "ok": true, "paused": false })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
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.replay.driver.as_mut() {
r.set_speed(spd);
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
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.replay.driver.as_mut() {
r.seek(ms).map_err(|e| format!("{e}"))?;
Some(r.position_ms())
} else {
None
};
if let Some(pos) = pos {
self.reset_studio_playback_ticks();
Ok(Some(json!({ "ok": true, "position_ms": pos })))
} else {
Ok(Some(json!({ "error": "no active replay" })))
}
}
Some("status") => {
if let Some(r) = self.replay.driver.as_ref() {
Ok(Some(json!({
"ok": true,
"active": true,
"position_ms": r.position_ms(),
"duration_ms": r.duration_ms(),
"progress": r.progress(),
"speed": r.speed(),
"paused": r.is_paused(),
"finished": r.is_finished(),
})))
} else {
Ok(Some(json!({ "ok": true, "active": false })))
}
}
Some("close") => {
let closed = self.close_studio_replay();
Ok(Some(json!({ "ok": true, "closed": closed })))
}
_ => Ok(Some(json!({ "error": "unknown studio replay subcommand" }))),
},
Some("screen") if super::studio::EDITOR => self.studio_screen_control(words),
_ => Ok(Some(json!({ "error": "unknown studio command" }))),
}
}
pub(super) fn start_studio_recording(&mut self, name: Option<&str>) -> Result<Value> {
let Some(r) = self.remote.as_ref() else {
return Ok(json!({ "ok": false, "error": "no active world session" }));
};
if r.session.is_recording() {
return Ok(json!({ "ok": false, "error": "recording already active" }));
}
let _ = std::fs::create_dir_all("recordings");
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let filename = match name {
Some(n) if !n.is_empty() => format!("{}_{}.mcpr", n, ts),
_ => format!("recording_{}.mcpr", ts),
};
let path = PathBuf::from("recordings").join(filename);
let server_address = if r.session.address.is_empty() {
"local".to_owned()
} else {
r.session.address.clone()
};
let is_singleplayer = server_address == "local";
let meta = Metadata::new(
server_address,
is_singleplayer,
horizon_core::release::VERSION.into(),
);
match horizon_client::studio::StudioRecorder::start(path.clone(), meta) {
Ok(recorder) => {
r.session.start_recording(recorder, |recorder| {
self.dump_world_snapshot_to_recorder(recorder);
});
Ok(json!({ "ok": true, "path": path.display().to_string() }))
}
Err(e) => Ok(json!({ "ok": false, "error": e.to_string() })),
}
}
pub(super) fn stop_studio_recording(&mut self) -> Result<Value> {
let Some(r) = self.remote.as_ref() else {
return Ok(json!({ "ok": false, "error": "no active world session" }));
};
match r.session.stop_recording() {
Some(Ok(path)) => Ok(json!({ "ok": true, "path": path.display().to_string() })),
Some(Err(e)) => Ok(json!({ "ok": false, "error": e.to_string() })),
None => Ok(json!({ "ok": false, "error": "no active recording" })),
}
}
pub(super) fn studio_recording_status(&self) -> Value {
if let Some(r) = self.remote.as_ref()
&& let Some(status) = r.session.recorder_status()
{
json!({
"ok": true,
"active": status.active,
"path": status.path.display().to_string(),
"elapsed_ms": status.elapsed.as_millis(),
"packet_count": status.packet_count,
"bytes_written": status.bytes_written,
})
} else {
json!({ "ok": true, "active": false })
}
}
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
if let Err(error) = self.save_studio_camera_track() {
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
// The recordings browser can be opened while the title screen is
// available but the slower world tables are still loading. Replay
// setup builds the normal client registries, which require `rest`.
if self.remote.is_none() && self.res.rest.get().is_none() {
let pending = PendingWorldEntry::StudioReplay(PathBuf::from(path));
if self.defer_world_entry(pending)? {
return Ok(json!({ "ok": true, "pending": true }));
}
self.join_world_rest()?;
}
// Validate and index the archive before replacing the title menu or active world. A
// temp-file, metadata, or protocol error must leave the current screen usable.
let driver = match ReplayDriver::open(Path::new(path)) {
Ok(driver) => driver,
Err(error) => {
log::error!("Failed to open studio replay '{path}': {error}");
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
};
let standalone = self.replay.driver.is_none() && self.remote.is_none();
if self.remote.is_none()
&& let Err(error) = self.init_remote_for_replay()
{
log::error!("Failed to initialize studio replay session: {error}");
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
let duration_ms = driver.duration_ms();
let packets = driver.packet_count();
self.studio_replay_opened(&driver);
if self.replay.driver.is_none() {
self.replay.previous_camera_type = Some(self.camera_type);
self.replay.standalone = standalone;
}
self.replay.driver = Some(driver);
self.camera_type = super::perspective::CameraType::FirstPerson;
self.title = None;
self.screen = Screen::Playing;
self.capture(false);
self.dragging = false;
// Apply timestamp-zero login, player pose, and chunk packets before the first rendered
// frame so playback opens at the recorded player's viewpoint.
let _ = self.advance_studio_replay();
self.reset_studio_playback_ticks();
log::info!("Studio: Opened replay '{path}' ({duration_ms} ms, {packets} packets)");
Ok(json!({
"ok": true,
"duration_ms": duration_ms,
"packet_count": packets,
}))
}
pub(super) fn close_studio_replay(&mut self) -> bool {
self.studio_replay_closing();
let closed = self.replay.driver.take().is_some();
if !closed {
return false;
}
// 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.replay.standalone
|| self
.remote
.as_ref()
.is_some_and(|remote| remote.brand == "horizon-studio");
self.replay.standalone = false;
if let Some(camera_type) = self.replay.previous_camera_type.take() {
self.camera_type = camera_type;
}
self.replay.last_tick = None;
self.studio_replay_closed();
self.replay.virtual_tick_ms = 0.0;
self.capture(false);
if standalone {
if let Some(mut remote) = self.remote.take() {
remote.session.disconnect();
}
self.using = None;
self.end_mod_use();
if let Err(error) = self.finish_close_world() {
log::error!("Failed to close standalone Studio replay cleanly: {error}");
}
self.reset_studio_hand_state();
// A title-screen replay has no previous world perspective to
// restore. Start the next world in first person with visible hands.
self.camera_type = super::perspective::CameraType::FirstPerson;
if let Some(title) = self.title.as_mut() {
title.mode = horizon_client::title::Mode::Main;
}
} else if self.integrated.is_none() && self.lan.is_none() {
let _ = self.close_world();
if let Some(title) = self.title.as_mut() {
title.mode = horizon_client::title::Mode::Main;
}
}
closed
}
/// Dump current player pose, dimension login, center chunk, and resident loaded chunks
/// into the recording stream at timestamp 0ms so replays accurately reconstruct the world.
pub(super) fn dump_world_snapshot_to_recorder(
&self,
recorder: &horizon_client::studio::StudioRecorder,
) {
let Some(r) = self.remote.as_ref() else {
return;
};
let record_cb = |pkt: horizon_protocol::packets::play::Clientbound| {
let encoded = pkt.encode();
let mut reader = horizon_protocol::buf::Reader::new(&encoded);
if let Ok(id) = reader.varint() {
let payload = reader.rest();
recorder.record_packet_at(0, id, payload);
}
};
// 1. C::Login packet baseline
let login = horizon_protocol::packets::play::Clientbound::Login(
horizon_protocol::packets::play::Login {
player_id: r.entity_id,
hardcore: self.level.hardcore,
levels: vec!["minecraft:overworld".into()],
max_players: 20,
chunk_radius: self.settings.render_distance,
simulation_distance: self.settings.simulation_distance,
reduced_debug_info: false,
show_death_screen: true,
limited_crafting: false,
spawn: horizon_protocol::packets::play::SpawnInfo {
dimension_type: 0,
dimension: "minecraft:overworld".into(),
seed: 0,
game_type: 0,
previous_game_type: None,
debug: false,
flat: false,
last_death: None,
portal_cooldown: 0,
sea_level: 63,
},
online_mode: false,
enforces_secure_chat: false,
},
);
record_cb(login);
// 2. Center chunk and player position
let (pos, yaw, pitch) = if let Some(p) = &self.player {
(
glam::DVec3::new(p.position.x, p.position.y, p.position.z),
self.yaw,
self.pitch,
)
} else {
(glam::DVec3::ZERO, self.yaw, self.pitch)
};
let chunk_x = (pos.x.floor() as i32) >> 4;
let chunk_z = (pos.z.floor() as i32) >> 4;
let center_chunk = horizon_protocol::packets::play::Clientbound::SetChunkCacheCenter(
horizon_protocol::packets::play::SetChunkCacheCenter {
x: chunk_x,
z: chunk_z,
},
);
record_cb(center_chunk);
let player_pos = horizon_protocol::packets::play::Clientbound::PlayerPosition(
horizon_protocol::packets::play::PlayerPosition {
teleport: 0,
position: [pos.x, pos.y, pos.z],
delta: [0.0, 0.0, 0.0],
yaw,
pitch,
relatives: 0,
},
);
record_cb(player_pos);
// 3. Encode resident loaded chunks into LevelChunkWithLight packets
let biome_names: Vec<String> = world_biomes!(self)
.list
.iter()
.map(|b| b.name.clone())
.collect();
let registry: Vec<String> = r
.registries
.registries
.get("minecraft:worldgen/biome")
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
.unwrap_or_else(|| biome_names.clone());
let encoder = crate::lan::chunks::ChunkEncoder::with_signs(
self.res.facts().states.iter().map(|s| {
(
s.name.clone(),
s.fluid_level > 0 || s.waterlogged,
s.sign_form(),
)
}),
&r.builtin,
&biome_names,
&registry,
);
let mut count = 0;
for chunk in self.world.chunks.values() {
let level_chunk = encoder.encode(chunk, &self.world.block_entities);
record_cb(
horizon_protocol::packets::play::Clientbound::LevelChunkWithLight(level_chunk),
);
count += 1;
}
log::info!(
"Studio Recorder: Dumped initial world snapshot ({count} chunks) at timestamp 0ms"
);
}
}
@@ -487,7 +487,7 @@ pub(super) fn load_resources(
};
let resources = Arc::new(resources);
if menu {
horizon_client::telemetry::mark_core_ready();
horizon_client::play_session::core_ready();
log::info!(
"Horizon: core-ready after {} ms",
started.elapsed().as_millis()
@@ -526,7 +526,7 @@ pub(super) fn load_resources(
.set(rest)
.map_err(|_| "world resources already loaded".to_owned())?;
progress.set(9);
horizon_client::telemetry::mark_rest_ready();
horizon_client::play_session::rest_ready();
log::info!(
"Horizon: rest-ready after {} ms",
started.elapsed().as_millis()
@@ -29,13 +29,12 @@ pub(super) struct RespawnConfig {
pub dim: Dim,
pub pos: [i32; 3],
pub yaw: f32,
pub pitch: f32,
pub forced: bool,
}
/// A respawn point as a player's save keeps it (`respawn`): dimension,
/// block, yaw, pitch and whether it is forced.
pub(super) type SavedRespawn = (String, [i32; 3], f32, f32, bool);
/// block, yaw and whether it is forced.
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
impl RespawnConfig {
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
@@ -44,37 +43,19 @@ impl RespawnConfig {
dim,
pos,
yaw: wrap_degrees(f64::from(yaw)) as f32,
pitch: 0.0,
forced,
}
}
/// `SetSpawnCommand`: the forced literal, with its already parsed angles.
pub(super) fn forced(dim: Dim, pos: [i32; 3], yaw: f32, pitch: f32) -> Self {
Self {
dim,
pos,
yaw,
pitch,
forced: true,
}
}
pub(super) fn from_saved((dim, pos, yaw, pitch, forced): &SavedRespawn) -> Self {
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
Self {
dim: Dim::from_id(dim),
pos: *pos,
yaw: *yaw,
pitch: *pitch,
forced: *forced,
}
}
pub(super) fn saved(&self) -> SavedRespawn {
(
self.dim.id().to_owned(),
self.pos,
self.yaw,
self.pitch,
self.forced,
)
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
}
}
@@ -199,7 +180,6 @@ impl Game<'_> {
dim: self.dim,
pos,
yaw: 0.0,
pitch: 0.0,
forced: false,
};
// RespawnConfig.isSamePosition: dimension and block.
@@ -498,14 +478,14 @@ impl Game<'_> {
rule.can_set_spawn.test(self.dark_outside())
}
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
/// dimension: position and angles, using an anchor's charge; a bed's
/// dimension: the position and yaw, using an anchor's charge; a bed's
/// stand-up spot. A forced point (`spawnpoint`) that is no anchor or bed
/// holds if a player fits in it; anything else is gone.
pub(super) fn find_respawn_and_use(
&mut self,
c: RespawnConfig,
consume: bool,
) -> Result<Option<(Vec3, f32, f32)>> {
) -> Result<Option<(Vec3, f32)>> {
let state = self.world.block(c.pos[0], c.pos[1], c.pos[2]);
if !(self.is_anchor(state)
&& (c.forced || self.anchor_charges(state) > 0)
@@ -515,7 +495,7 @@ impl Game<'_> {
if self.is_bed(state) && self.bed_sets_spawn(state) {
let facing = self.bed_forward(state);
let stand = self.bed_stand_up(c.pos, facing, c.yaw);
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)));
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
}
let [x, y, z] = c.pos;
let free = c.forced
@@ -523,7 +503,7 @@ impl Game<'_> {
&& self.possible_to_respawn_in(state)
&& self.possible_to_respawn_in(self.world.block(x, y + 1, z));
let at = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.1, f64::from(z) + 0.5);
return Ok(free.then_some((at, c.yaw, c.pitch)));
return Ok(free.then_some((at, c.yaw)));
}
let stand = self.anchor_stand_up(c.pos);
if !c.forced
@@ -533,7 +513,7 @@ impl Game<'_> {
{
self.level_set(c.pos, next)?;
}
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos), 0.0)))
Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))))
}
}
@@ -580,354 +560,3 @@ mod tests {
assert!((look_at_yaw(east, head) - 90.0).abs() < 1.0e-4);
}
}
#[cfg(test)]
mod connected_spawnpoint_rotation_tests {
use crate::game::{Game, lan::LanSettings};
use horizon_client::{
chunk::Chunk,
lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig, playerdata},
};
use horizon_protocol::packets::{
configuration::ClientInformation,
play::{self, Clientbound as C, Serverbound as S},
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn command(game: &mut Game<'_>, text: &str) {
game.chat_capture = Some(Vec::new());
game.ui.registries.permission = 4;
let dispatcher = horizon_client::command::vanilla::build_for(false, true);
let parsed = dispatcher.parse(text, &game.ui.registries);
let (ctx, _) = parsed
.executable()
.expect("actual registered dedicated command");
let (root, ctx) = (parsed.context.clone(), ctx.clone());
assert_eq!(game.run_chain(&root, &ctx).unwrap(), 1, "{text}");
assert!(
!game.chat_capture.take().unwrap().is_empty(),
"actual command feedback {text}"
);
}
#[test]
#[ignore = "prepared resources and isolated scratch homes/cwd"]
fn command_forced_free_point_survives_real_guest_death_and_respawn_packet() {
let mut game = crate::game::native_machines::tests::server_game();
game.set_allow_commands(true);
let scratch = std::env::temp_dir().join(format!(
"horizon-forced-spawn-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
// Fixture setup through the existing column insertion API; admit every lit column below.
for x in -2..=2 {
for z in -2..=2 {
game.streaming
.insert_remote(&mut game.world, Chunk::empty(x, z));
assert!(game.streaming.is_lit(x, z));
}
}
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
let air = game
.pack
.assets
.registry
.default_state("minecraft:air")
.unwrap();
game.world.set(12, 99, 12, stone).unwrap();
for y in [100, 101] {
game.world.set(12, y, 12, air).unwrap();
}
assert_eq!(game.world.block(12, 99, 12), stone);
assert!(game.world.loaded(12, 12));
game.level.world_spawn = Some([4, 100, 4]);
game.level.world_spawn_angle = [-30.0, -5.0];
game.lan_host(
LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
listen: false,
announce: false,
..NetConfig::loopback(0, String::new(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 7,
uuid: 77,
name: "Alice".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:7".parse().unwrap(),
expected: false,
configured: Default::default(),
},
7,
[4.5, 100.0, 4.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.health = 20.0;
guest.game_mode = 0;
guest.yaw = 0.0;
guest.pitch = 0.0;
game.lan.as_mut().unwrap().hub.guests.push(guest);
let clock = game.world.game_time;
command(&mut game, "spawnpoint Alice 12 100 12 45 10");
let host = game.lan.as_ref().unwrap();
assert!(host.owner.is_none());
let guest = &host.hub.guests[0];
assert_eq!(
(guest.uuid, guest.health, guest.game_mode, guest.position),
(77, 20.0, 0, [4.5, 100.0, 4.5])
);
let tag = playerdata::player_tag(guest);
let respawn = tag
.get("respawn")
.expect("actual serialized personal configuration")
.clone();
assert_eq!(
respawn.get("yaw"),
Some(&horizon_world::nbt::Tag::Float(45f32.to_bits()))
);
assert_eq!(
respawn.get("pos"),
Some(&horizon_world::nbt::Tag::IntArray(vec![12, 100, 12]))
);
command(&mut game, "kill Alice");
game.tick_lan(false);
let mut host = game.lan.take().unwrap();
assert_eq!(host.hub.guests[0].uuid, 77);
assert_eq!(host.hub.guests[0].health, 0.0);
assert!(
host.hub.guests[0].dead,
"actual command death before request"
);
for _ in rx.try_iter() {}
game.lan_guest_packet(
&mut host,
7,
S::ClientCommand(play::ClientCommand { action: 0 }),
);
game.tick_server_travel(&mut host);
assert!(
!game.on_server_trip(7),
"admitted lit columns permit actual arrival"
);
let guest = &host.hub.guests[0];
assert_eq!(guest.uuid, 77);
assert_eq!(guest.health, 20.0);
assert!(!guest.dead);
assert_eq!(guest.position, [12.5, 100.1, 12.5]);
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
assert_eq!(game.world.game_time, clock);
let physical = [guest.yaw.to_bits(), guest.pitch.to_bits()];
let awaiting_id = guest
.awaiting_teleport
.expect("actual final arrival awaits its acknowledgement")
.0;
let mut packets = Vec::new();
for out in rx.try_iter() {
match out {
Out::Packet(p) => packets.push(p),
Out::Packets(p) => packets.extend(p),
Out::Disconnect(_) => panic!("unexpected disconnect"),
}
}
let mut positions = Vec::new();
let mut respawns = 0;
for p in packets {
println!("complete actual Hub frame {p:?}");
let mut r = horizon_protocol::buf::Reader::new(&p);
let id = r.varint().unwrap();
match C::decode(id, r.rest()).unwrap() {
C::PlayerPosition(p) => positions.push(p),
C::Respawn(_) => respawns += 1,
_ => {}
}
}
assert_eq!(respawns, 1);
assert!(!positions.is_empty());
println!("all actual arrival positions {positions:?}");
let selected: Vec<_> = positions
.iter()
.filter(|p| p.teleport == awaiting_id)
.collect();
assert_eq!(
selected.len(),
1,
"select by actual authoritative pending id, never expected position/pitch"
);
let p = selected[0];
assert_eq!(p.position, [12.5, 100.1, 12.5]);
// All successful command/death/arrival/identity/world controls precede the intended regression.
assert_eq!(
(
respawn.get("pitch"),
physical,
[p.yaw.to_bits(), p.pitch.to_bits()]
),
(
Some(&horizon_world::nbt::Tag::Float(10f32.to_bits())),
[45f32.to_bits(), 10f32.to_bits()],
[45f32.to_bits(), 10f32.to_bits()]
)
);
drop(host);
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
}
#[cfg(test)]
mod pitch_preservation_controls {
use super::*;
#[test]
fn wrapped_existing_callers_default_to_positive_zero_pitch() {
let c = RespawnConfig::wrapped(Dim::Overworld, [5, 100, 5], 45.0, false);
assert_eq!(c.yaw.to_bits(), 45f32.to_bits());
assert_eq!(c.pitch.to_bits(), 0.0f32.to_bits());
assert!(!c.forced);
}
/// Added source/model controls; these do not add a connected bed/anchor runtime claim.
#[test]
#[ignore = "prepared resources and isolated scratch homes/cwd"]
fn bed_anchor_facing_and_invalid_personal_fallback_ignore_stored_pitch() {
let mut game = crate::game::native_machines::tests::server_game();
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
for x in 0..16 {
for z in 0..16 {
game.world.set(x, 99, z, stone).unwrap();
}
}
let state = |game: &Game<'_>, name: &str, properties: &[(&str, &str)]| {
let registry = game.pack.assets.registry;
let id = registry.default_state(name).unwrap();
let mut value = registry.state(id).unwrap().clone();
for (key, value_) in properties {
value.properties.insert((*key).into(), (*value_).into());
}
registry.resolve(&value).unwrap()
};
let head = state(
&game,
"minecraft:red_bed",
&[("facing", "north"), ("part", "head")],
);
let foot = state(
&game,
"minecraft:red_bed",
&[("facing", "north"), ("part", "foot")],
);
game.world.set(5, 100, 5, head).unwrap();
game.world.set(5, 100, 6, foot).unwrap();
assert!(game.is_bed(head) && game.bed_sets_spawn(head));
let bed = RespawnConfig {
dim: Dim::Overworld,
pos: [5, 100, 5],
yaw: 45.0,
pitch: 0.0,
forced: true,
};
let zero = game
.find_respawn_and_use(bed, false)
.unwrap()
.expect("valid bed geometry");
let stored = game
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..bed }, false)
.unwrap()
.unwrap();
assert_eq!(stored, zero);
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, bed.pos).to_bits());
// Existing built-in dimension facts select the anchor rule; no new world or actor API.
game.dim = Dim::Nether;
let anchor = state(&game, "minecraft:respawn_anchor", &[("charges", "2")]);
game.world.set(10, 100, 10, anchor).unwrap();
assert!(game.anchor_works() && game.is_anchor(anchor));
let c = RespawnConfig {
dim: Dim::Nether,
pos: [10, 100, 10],
yaw: 45.0,
pitch: 0.0,
forced: true,
};
let zero = game
.find_respawn_and_use(c, false)
.unwrap()
.expect("valid anchor geometry");
let stored = game
.find_respawn_and_use(RespawnConfig { pitch: 75.0, ..c }, true)
.unwrap()
.unwrap();
assert_eq!(stored, zero);
assert_eq!(stored.2.to_bits(), 0.0f32.to_bits());
assert_eq!(stored.1.to_bits(), look_at_yaw(stored.0, c.pos).to_bits());
assert_eq!(
game.world.block(10, 100, 10),
anchor,
"existing forced-charge behavior"
);
game.dim = Dim::Overworld;
game.level.world_spawn = Some([4, 100, 4]);
game.level.world_spawn_angle = [-30.0, -5.0];
game.world.set(12, 100, 12, stone).unwrap();
let invalid = RespawnConfig {
dim: Dim::Overworld,
pos: [12, 100, 12],
yaw: 45.0,
pitch: 10.0,
forced: true,
};
game.respawn_config = Some(invalid);
assert!(game.resolve_server_respawn(invalid).is_none());
assert!(game.respawn_config.is_none());
assert_eq!(game.level.world_spawn, Some([4, 100, 4]));
assert_eq!(game.level.world_spawn_angle, [-30.0, -5.0]);
}
}
-1
View File
@@ -362,7 +362,6 @@ impl Game<'_> {
path,
size: Some([64, 64]),
scaled: false,
background: true,
});
}
}
+5 -7
View File
@@ -823,7 +823,7 @@ impl Game<'_> {
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
self.client_hooks_frame();
self.cosmetics_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,12 +900,10 @@ impl Game<'_> {
let board = &self.score.board;
let objective = board.display_objective(sb::SLOT_LIST);
let up = |id: u128| upside_down.contains(&id);
// 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;
// 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);
let inputs = TabInputs {
players: &players,
board,
@@ -138,7 +138,10 @@ impl Game<'_> {
return Err("a screenshot sequence is already running".into());
}
let renderer = self.renderer.as_ref().ok_or("no renderer yet")?;
if self.pending_screenshot.is_some() || renderer.screenshot.is_some() {
if self.pending_screenshot.is_some()
|| self.ui.screenshot_message.is_some()
|| renderer.screenshot.is_some()
{
return Err("a screenshot is already pending".into());
}
if let Some(refusal) = self.sequence_refusal() {
@@ -230,7 +233,6 @@ impl Game<'_> {
path,
size: None,
scaled: false,
background: false,
});
renderer.screenshot_result = None;
}
@@ -644,14 +644,11 @@ impl Game<'_> {
/// in the level in place: where it stands up, facing the anchor (with
/// the deplete sound) or the bed; else the point is forgotten, and the
/// caller tells the player (`Hub::missing_respawn_block`).
pub(super) fn resolve_server_respawn(
&mut self,
config: RespawnConfig,
) -> Option<(Vec3, f32, f32)> {
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
let [x, y, z] = config.pos;
let anchor = self.is_anchor(self.world.block(x, y, z));
match self.find_respawn_and_use(config, true) {
Ok(Some((at, yaw, pitch))) => {
Ok(Some((at, yaw))) => {
if !config.forced && anchor {
let [x, y, z] = config.pos.map(f64::from);
self.audio.play(
@@ -661,7 +658,7 @@ impl Game<'_> {
1.0,
);
}
Some((at, yaw, pitch))
Some((at, yaw))
}
Ok(None) | Err(_) => {
let mut cancel = false;
@@ -799,10 +796,7 @@ impl Game<'_> {
let yaw = host.hub.guests[i].yaw;
let (position, yaw) = if let Some(config) = trip.respawn {
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
Some(Some((at, yaw, pitch))) => {
host.hub.guests[i].pitch = pitch;
(at, yaw)
}
Some(Some(found)) => found,
_ => {
host.hub.missing_respawn_block(trip.conn);
self.respawn_at_world_spawn(host, trip.conn);
-2
View File
@@ -261,8 +261,6 @@ 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,7 +126,6 @@ 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
@@ -136,7 +135,6 @@ 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.
@@ -301,31 +299,16 @@ impl Split<'_> {
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let made = if options.exists() {
None
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
} 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);
horizon_hooks::engine::set_seat(turn);
Some(new)
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`).
@@ -53,7 +53,7 @@ pub(super) struct StartupMenu {
pub reload_packs: bool,
/// Browse mods' worker (`mod_browser`).
mod_jobs: super::mod_browser::Jobs,
pub perf: horizon_client::telemetry::Session,
pub perf: horizon_client::play_session::Session,
}
impl StartupMenu {
@@ -1268,7 +1268,7 @@ impl Loading {
let output = self
.renderer
.as_ref()
.map_or(Default::default(), |r| r.output());
.map_or(Default::default(), |r| *r.output());
self.menu
.options
.draw(&self.settings, output, batch, &gui.atlas, self.menu.cursor);
@@ -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]);
File diff suppressed because it is too large. Load diff
+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 {
+15 -19
View File
@@ -169,33 +169,29 @@ impl Game<'_> {
/// Leaving the game with a world open (window closed, Quit, `quit`):
/// menu items go back (Player.closeContainer), then the world is saved,
/// once.
/// Keeps this play session's performance summary for the launcher to send, if the player
/// allows performance statistics (`horizon_client::telemetry`), and starts the next one.
/// Ends this play session for the hooks (`horizon_client::play_session`): opt-in telemetry
/// keeps its performance summary for the launcher to send, if the player allows it.
pub(super) fn finish_perf(&mut self) {
// One session per computer: P1's (a splitscreen player's would count it again).
if self.options.split_guest {
return;
}
let s = &self.settings;
let settings = horizon_telemetry::payload::Settings {
render_distance: s.render_distance.clamp(0, 255) as u8,
simulation_distance: s.simulation_distance.clamp(0, 255) as u8,
// 260 is the slider's "unlimited".
max_fps: if s.max_fps >= 260 {
0
} else {
s.max_fps.min(u32::from(u16::MAX)) as u16
},
vsync: s.vsync,
fullscreen: s.fullscreen,
graphics: s.graphics_preset.clone(),
};
// Again now: on Wayland the window is on a screen only once it has been shown.
if let Some(window) = &self.window {
horizon_client::telemetry::note_display(window);
}
self.perf
.finish(&horizon_client::logging::game_home(), settings);
let s = &self.settings;
let home = horizon_client::logging::game_home();
self.perf.finish(&horizon_hooks::WorldLeft {
home: &home,
render_distance: s.render_distance,
simulation_distance: s.simulation_distance,
// 260 is the slider's "unlimited".
max_fps: (s.max_fps < 260).then_some(s.max_fps),
vsync: s.vsync,
fullscreen: s.fullscreen,
graphics: &s.graphics_preset,
});
}
pub(super) fn save_for_exit(&mut self) -> Result<()> {
@@ -245,7 +241,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.replay.playing() {
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() {
+4 -4
View File
@@ -173,10 +173,10 @@ pub struct Guest {
/// Where the player respawns (a bed's spot; `ServerPlayer.respawnConfig`),
/// None for the world spawn.
pub spawn: Option<[f64; 3]>,
/// A block-resolved personal respawn point: its dimension, block, yaw,
/// pitch and whether it is forced, as the player's save keeps it
/// (`respawn`).
pub respawn_elsewhere: Option<(String, [i32; 3], f32, f32, bool)>,
/// A respawn point outside the Overworld (a respawn anchor, or one
/// `spawnpoint` set there): its dimension, block, yaw and whether it is
/// forced, as the player's save keeps it (`respawn`).
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
pub awaiting_teleport: Option<(i32, [f64; 3])>,
+1 -1
View File
@@ -343,7 +343,7 @@ struct Kept {
effects: Option<crate::effects::PlayerEffects>,
game_mode: Option<(i32, bool, f32)>,
position: Option<([f64; 3], [f64; 3], f32, f32, f32, bool, Option<[i32; 4]>)>,
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, f32, bool)>)>,
spawn: Option<(Option<[f64; 3]>, Option<(String, [i32; 3], f32, bool)>)>,
recipes: Option<crate::recipe_book::Settings>,
/// The body's values: its fall goes with the position, its air and fire with the
/// health.
+20 -3
View File
@@ -771,9 +771,26 @@ fn swim(p: &mut Poses, pos: f32, h: &Hum) {
lerp(amount, x, 0.3 * (pos * 0.333_333_34).mcos()),
);
}
/// 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};
/// 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;
}
/// Apply the mob's model animation to `p` (at rest), then the pose laid over it, if any.
pub fn setup(
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff. Show more