Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 8642db5c99 Renderer: the ExternalPipeline hook, with the shadow request and its culling in gfx/external.rs
Step a of #14035 (layer plan M6). An external shader pipeline implements
ExternalPipeline; the renderer calls begin_frame and shadow_request on world
frames, resize and reset_history, and draws vanilla's way where it doesn't
answer. Nothing sets one yet: each point is one Option test.

ShadowView moves from pack_post (re-exported there) and ShadowCull,
shadow_box, shadow_runs and shadow_casters from gfx.rs, unchanged: the
engine's side of the shadow frame point.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 05:17:39 +01:00
184 changed files with 1510 additions and 8631 deletions

No files matched your search

-34
View File
@@ -1,34 +0,0 @@
name: runner watch
# Every 30 minutes: one issue when jobs wait over 2 hours for a self-hosted runner nobody runs (the maintainer's
# machines are off), closed again once nothing waits (tools/ci/runner_watch.py, docs/operations.md). It runs on
# GitHub's runners, never ours: it must work when every self-hosted runner is gone.
# gh workflow run "runner watch" -f dry_run=true
on:
schedule:
- cron: '11,41 * * * *'
workflow_dispatch:
inputs:
dry_run:
description: Only print what it would do with the issue
type: boolean
default: false
permissions:
actions: read
contents: read
issues: write
concurrency:
group: runner-watch
cancel-in-progress: false
jobs:
watch:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Jobs waiting for a self-hosted runner
env:
GH_TOKEN: ${{ github.token }}
run: python3 tools/ci/runner_watch.py ${{ inputs.dry_run && '--dry-run' || '' }}
+45 -145
View File
@@ -2,9 +2,6 @@
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
>
> Amended 2026-11-10 by the founder (three kinds of action, elections by tier, who may do what, and council poll P41:
> the mods decide moderation matters). The council can overturn this amendment by poll.
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
@@ -53,13 +50,13 @@ the Horizon Team, for example.
| Level | What it can do | How you get in | How you leave | 18+ |
| --- | --- | --- | --- | --- |
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; vote in their group's elections (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; vote in the group's elections and votes when active | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | Given by any moderator (a free action, section 2.1) | Resign, 30 days inactive, or removed by a moderator, the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | Elected by the mods (section 5.3); council veto within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, recalled by the mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Elected by the mods, from the mods with at least 30 days (section 5.3); council veto within 24 h | Resign, 30 days inactive, recalled by the mods, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Elected by the group's active members and the Team (section 5.3); council veto within 24 h; 30 days probation | Resign, 30 days inactive (automatic), recalled by the same electorate, council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Elected by the Team, 2/3 (section 5.3); council veto within 24 h; 30 days probation | Resign, 60 days without a merge or review (automatic), recalled by the Team (2/3), council poll (2/3, without the person) | **Yes** |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
@@ -67,55 +64,6 @@ owner, Discord server owner, the Microsoft app approved by Mojang, the domains,
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
like everyone else.
### 2.1 Three kinds of action
Most things need no vote at all. A vote is for power over other people, and for decisions that bind a whole body.
| Kind | What | How |
| --- | --- | --- |
| **Free** | Everyday actions inside your own role (the list below, and anything of the same weight) | Just do it. Kira logs the ones she sees in #staff-log. The tier above can reverse it; if people disagree, the tier above votes. A pattern of abuse is a recall |
| **Body vote** | Giving or taking a role that carries power (an election), or a decision that belongs to one body: the mods, the Team, a group | `/elect` or `/vote body:` in Kira (section 5.3). Any council member can call a veto within 24 hours |
| **Council poll** | Rules, money, accounts and ownership, the council's own members, vetoes, anything that spans several bodies | `/poll` (section 4) |
Free actions, for example:
- a lead gives or removes their group's helper role (🧩 Horizon Modding Team), through Kira;
- a moderator gives or removes 🤝 Support, through Kira;
- a moderator times out, deletes, quarantines, or changes a channel's slowmode or topic; the Chief of Mods bans and
unbans;
- the Team merges and labels;
- a server operator restarts the official server, or prunes backups and chunks by the written rule;
- a lead pins, renames or creates threads in their group's channels;
- Kira's automatic actions.
**The 24-hour rule.** Every vote lasts 24 hours at most, and closes earlier once its result can't change. Only council
polls on this document, the licence, money and the GitHub organisation's ownership last 48 hours. A veto is called
within 24 hours of a result.
### 2.2 Who may do what
What each post decides, and what it holds on Discord, on the official server and on GitHub. Anything not listed is a
council matter.
| Post | Decides | Discord | Official server | GitHub |
| --- | --- | --- | --- | --- |
| **Member** | — | Chat, threads, picks group roles | Play | Issues, pull requests, `/claim` |
| **Group helper** (🧩 Horizon Modding Team) | — | Writes in the group's team channel | — | — |
| **🤝 Support** | Closes support posts | Manages threads in #horizon-support | — | — |
| **🛡️ Moderator** | Moderation matters, by the mods' vote (section 7) | Time out, delete messages, manage threads and nicknames, audit log, voice mute and move; quarantine through Kira; the staff channels | In-game op if also elected operator | — |
| **🛡️ Chief of Mods** | A moderator's, plus bans and unbans; suspends a mod at once; breaks ties in the mods' votes | Plus Ban | As a moderator | — |
| **Server operator** | In-game moderation by the server rules | — | Op (the server's ops list); no panel, no `server.toml` | — |
| **🧭 Server & SMP lead** | The server's settings, by the written rules (backup first, warning in game) | As every lead | A panel sub-user once a council accounts poll gives one | As every lead |
| **🧭 Group lead** | The group's free actions; gives and removes its helper role | In the group's channels: delete messages, manage and pin threads, ping the group; the helper role through Kira | — | Triage on the group's labels through Kira (no repository role) |
| **🌌 Horizon Team** | Merges; elects Team members | #horizon-team and the staff channels. The role still carries timeout and delete; whether it keeps them is a mod matter | — | `horizon-team`: write on horizon, horizon-mods, horizon-mixins, horizon-native-mod-template and horizon-server; also kira and horizon-site if P37 passes |
| **🔧 Server Manager** | — | Manage Channels and Manage Roles (the server's layout); given by council poll | — | — |
| **🏛️ Council** | Council polls, vetoes, emergencies (any 2) | A moderator's permissions plus Ban, mention @everyone and pin; #council | Panel sub-users for two members if P38 passes | The `council` team: code-owner reviews and the release tools' owner powers if P35 passes, security managers if P36 passes; a second organisation owner if P28 passes |
| **Account holder** (the founder) | Carries out decisions on his accounts (section 11) | Server owner | The panel's owner account | Organisation owner |
Kira applies a passed election everywhere she can: Discord roles herself, GitHub team membership when the council has
given her a token for it. Whatever she can't do (a panel sub-user, the ops list, a GitHub team without a token) she
posts as a list of clicks for a named person.
## 3. Each level in detail
### Member
@@ -136,13 +84,13 @@ an issue or a pull request on GitHub, or a request for a council poll (any counc
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
least one counted action in the group's area (section 5.1) in the last 60 days. Active group members vote in the
group's elections and decisions (section 5.3).
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
nominate a lead or vote in the group's advisory poll.
### 🤝 Support
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
The role carries no permission, so it has no age rule. A moderator gives it, and takes it back, through Kira.
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
### 🛡️ Moderators and the Chief of Mods
@@ -203,7 +151,7 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
| --- | --- |
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
| How long | 24 hours; 48 hours for this document, the licence, money and the GitHub organisation's ownership. It closes early only if every council member has voted |
| How long | 24 hours. It closes early only if every council member has voted |
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
| Simple majority | More Yes than No |
@@ -217,20 +165,20 @@ don't go through the filter or a probation. They do make the 18+ declaration (se
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members |
| Vetoing a body's vote or election (section 5.3) | Removing a council member or a Team member (without the person concerned) |
| Removing a group lead, a moderator, the Chief of Mods or Support (their bodies can also recall them) | Rules: this document, the server rules, the contributing policy, the licence |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
| Ending a probation early | Money (section 12) |
| Confirming an emergency action | Account handover steps (section 11) |
| Deciding for a body that has fewer than 3 voters | Waiving part of the selection filter (section 5.4) |
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
### 4.4 Discussion
Polls that add someone to the council start with a **24-hour public discussion**. Other polls, and elections, are
discussed in their own thread while they run. A member who wants more time votes No and says
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
why. A failed poll can come back after 7 days.
### 4.5 Conflicts of interest
@@ -272,7 +220,7 @@ false age declaration (section 9).
## 5. The selection filter
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
measures the work, and only people above a floor can be nominated. Their body then elects them (section 5.3).
measures the work, and only people above a floor can be nominated. The council then decides among them.
### 5.1 What Kira measures
@@ -306,63 +254,25 @@ A nomination is refused below these floors.
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
Horizon and out of reach for people who don't.
### 5.3 Elections
### 5.3 The path to a role
The body that works with a person elects them. Kira runs it: `/elect post: member: [group:]`, and `action: remove`
for a recall.
| Post | Who votes | Passes with | Who can start it |
| --- | --- | --- | --- |
| **🛡️ Moderator** | The mods (🛡️ Mod and 🛡️ Chief of Mods) | Simple majority; the Chief of Mods breaks a tie | Any mod, a council member |
| **🛡️ Chief of Mods** | The mods; candidates are mods for 30 days or more | Simple majority | Any mod, a council member |
| **Server operator** | The mods and the 🧭 Server lead | Simple majority | The Server lead, the Chief of Mods, a council member |
| **🧭 Group lead** (a co-lead once the group has 8 active members) | The group's active members, and the Team | Simple majority | The current lead, a Team member, a council member |
| **🌌 Horizon Team** | The Team | Two thirds, and at least 3 Yes | A Team member, a council member |
| **🏛️ Council** | The council, by poll after a 24-hour discussion (section 4) | Two thirds, and at least 3 Yes | A council member |
| 🤝 Support, a group's helper role | Nobody: a free action (section 2.1) | — | A moderator; the group's lead |
An **active group member** has held the group's role for 14 days or more (Kira counts from when she first saw it)
and has at least one merged pull request or review with the group's label in the last 60 days.
1. **Start.** Kira checks the floor (5.2) and the 18+ declaration. If the candidate falls short, the person starting
gets a private "not now", and the candidate a private message saying what is missing. Nothing is posted and no
reason is ever shown to anyone else. Kira checks what she can measure (verified GitHub, merged pull requests and
reviews, overall and by the group's label, rank, days in the server, Kira level, clean record, the declaration);
the voters judge the rest.
2. **Vote, 24 hours,** in the body's channel, discussed in the vote's thread. Yes, No or Abstain, changeable while it
runs. Kira shows how many voted, never who. It closes early once the result can't change.
3. **Result,** applied at once. Kira gives or takes the Discord role and, with a token, the GitHub team; she lists the
rest as clicks for a named person. Only the result is published, never the ballots.
4. **Veto, 24 hours.** Any council member can call it with the button under the result. A council poll (simple
majority, without the people involved) decides; if it passes, Kira undoes the result.
5. **Probation, 30 days,** for leads and Team members. It ends by itself unless someone who can vote starts a recall.
The council can always remove someone by its own poll. **Body votes** work the same way for a body's own decisions:
`/vote body:mods`, `body:team` or `body:group`, simple majority, 24 hours, the same veto.
#### Hard cases
| Case | What happens |
| --- | --- |
| Fewer than 3 can vote | The council decides by poll |
| Quorum | A third of the electorate, and at least 3 ballots (Abstain counts). Without it, nothing changes |
| Tie | Fails, and nothing changes. In the mods' votes, the Chief of Mods' ballot breaks it |
| The candidate | Doesn't vote on themselves, and can decline (the election ends). The person who started it votes normally |
| Conflicts | Whoever the vote is about, or who gave or received the moderation action in question, doesn't vote |
| Several candidates for one seat | Nominations within the first 12 hours join one approval ballot. The seat goes to the most approvals if that is at least half of the voters; otherwise a 24-hour run-off between the top two |
| A seat that is held | Changes only by a recall or by the 6-month confirmation |
| The council is already voting on it | No election starts for the same person and post |
| Cooldown | A failed or vetoed election can't be started again for the same person and post for 30 days. At most 3 elections run at once in a body |
| Vacancy | When a lead or the Chief of Mods leaves, Kira opens the seat's election herself: 24 hours of nominations, then the vote with everyone nominated. Nobody nominated: the seat stays open, and the tier above acts meanwhile. Kira never picks a candidate |
| Abuse of a free action | The tier above reverses it; a disagreement goes to its vote. A pattern is a recall |
| Emergency | Section 4.6. The Chief of Mods can still suspend a mod at once |
| Kira is down | The body may run the same vote as a Discord poll in its channel for 24 hours; Kira records the result when she is back |
#### Reminders, not nominations
Once a month in a group's channel, Kira says how to propose someone, only when the group has no lead or someone in it
meets a post's floor. When a seat opens, she posts once in the electorate's channel. Once a month in #council, she
lists privately the people who meet a floor. These are suggestions: only people start elections.
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
needs a second from another active group member or a council member.
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
`/apply`, if a council or Team member sponsors them.
- A **council member** can only be nominated by a council member.
- **Moderators** apply with `/apply`.
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
is short.
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
majority) ended it earlier.
### 5.4 Exceptions
@@ -389,8 +299,8 @@ holder or an org owner removes GitHub access when the Team role ends.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by an election in their group. If someone else in the group passes the
floor and is nominated within its first 12 hours, both go on the same ballot.
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
floor and is nominated, both go on the same poll.
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
is always possible (section 4.7).
@@ -429,9 +339,8 @@ the design system:
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
take that decision to the council;
- giving and removing the group's helper role, through Kira (a free action);
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
- asking the Team for reviews, adding `needs-proof`, and starting elections in the group.
- asking the Team for reviews, adding `needs-proof`, and nominating people.
### 6.4 What goes to the council
@@ -447,28 +356,21 @@ the design system:
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
channel. Kira posts once that the seat is open and how to start its election (section 5.3). **No lead is better than a lead who doesn't do the work:**
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
nobody is appointed just to fill the seat.
## 7. Moderation and appeals
- **The mods decide moderation matters** (council poll P41, imposed by the founder on 2026-11-10): who joins or
leaves the mod team, the mods' permissions and the staff channels, bans and unbans, and channel settings for
moderation. Everyday actions are free (section 2.1). Anything bigger is a mods' vote (`/vote body:mods`, simple
majority, 24 hours), and the Chief of Mods breaks ties. **The council keeps a veto:** within 24 hours of a mods'
decision, any council member can call a council poll, and a simple majority overturns it.
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
change the rules (2/3).
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
or the Chief of Mods, answers within 72 hours.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a mods' vote in #mod-chat. Mods who were
involved don't vote, and the council's veto applies. The answer is final for 90 days.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
Members who were involved don't vote. The answer is final for 90 days.
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the mods decide within 72 hours by
a recall vote. The mods recall the Chief of Mods the same way. The council can also remove either by poll.
- **Council members keep the ban permission** for emergencies and when no moderator is around. They log the ban like
any other, and the mods can reverse it by vote.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
(simple majority). The council removes the Chief of Mods the same way.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
@@ -556,9 +458,7 @@ and the founder carries it out.
## 13. Transparency
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
For polls that aren't about a person, each member's vote is shown once the poll closes. The results of elections
and body votes are posted there too; for elections, only the result.
- **#staff-log:** the free actions Kira sees (roles given or taken, bans, unbans), so the tier above can check them.
For polls that aren't about a person, each member's vote is shown once the poll closes.
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
+2 -95
View File
@@ -70,8 +70,6 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
other one 30 000 characters long), flood-book (100 pages of 1024 characters
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--ack-mode MODE chunk batch feedback: vanilla (default) or 64 (fixed chunks per tick);
one ACK per completed batch; use explore for a controlled pacing test
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
@@ -173,8 +171,6 @@ struct Options {
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
/// None uses vanilla estimation; --ack-mode 64 requests a fixed rate.
ack_rate: Option<f32>,
no_read: bool,
no_loaded: bool,
height: f64,
@@ -213,7 +209,6 @@ fn options() -> Result<Options, String> {
no_swing: false,
crits: false,
flood: 100,
ack_rate: None,
no_read: false,
no_loaded: false,
height: 24.0,
@@ -271,13 +266,6 @@ fn options() -> Result<Options, String> {
o.explore_speed = value.parse().map_err(|_| number("blocks a second"))?;
}
"--pvp" => o.pvp = value.parse().map_err(|_| number("true or false"))?,
"--ack-mode" => {
o.ack_rate = match value.as_str() {
"vanilla" => None,
"64" => Some(64.0),
_ => return Err(format!("--ack-mode {value}: use vanilla or 64")),
};
}
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
@@ -962,7 +950,6 @@ fn read(
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
ack_rate: Option<f32>,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
@@ -1048,8 +1035,7 @@ fn read(
batch.finished(r.varint().unwrap_or(0));
send(
play::Serverbound::ChunkBatchReceived(play::ChunkBatchReceived {
chunks_per_tick: ack_rate
.unwrap_or_else(|| batch.desired_chunks_per_tick()),
chunks_per_tick: batch.desired_chunks_per_tick(),
})
.encode(),
)
@@ -1153,8 +1139,7 @@ fn run_bot(
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
let ack_rate = o.ack_rate;
thread::spawn(move || read(reader, writer, bot, track, ack_rate, capture));
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1534,7 +1519,6 @@ fn main() {
"behaviour": o.behaviour,
"seed": o.seed,
"explore_speed": o.explore_speed,
"ack_mode": o.ack_rate.map_or("vanilla", |_| "64"),
"pvp": o.pvp,
"view_distance": o.view_distance,
"seconds": o.seconds,
@@ -1600,83 +1584,6 @@ mod tests {
AddEntity, Clientbound, MoveEntity, MoveEntityPos, RemoveEntities, SetEntityData, VecDelta,
};
/// Exercise the production reader over framed connections. Pings are barriers
/// so extra ACKs are observable without sleeps or timing guesses.
fn check_chunk_acks(ack_rate: Option<f32>) {
use horizon_protocol::connection::Pipe;
let (client, server) = Pipe::pair();
let (reader, writer) = Connection::local(client).split();
let mut server = Connection::local(server);
server
.reader
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
let bot = Arc::new(Bot::default());
bot.online.store(true, Ordering::SeqCst);
let handle = thread::spawn(move || {
read(
reader,
Arc::new(Mutex::new(writer)),
bot,
false,
ack_rate,
None,
)
});
for (nonce, size) in [0, 1, 5].into_iter().enumerate() {
server
.send(&raw(ids::play::clientbound::CHUNK_BATCH_START, |_| {}))
.unwrap();
server
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(
id,
ids::play::serverbound::PONG,
"batch start must not send an ACK"
);
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
server
.send(&raw(ids::play::clientbound::CHUNK_BATCH_FINISHED, |w| {
w.varint(size)
}))
.unwrap();
server
.send(&raw(ids::play::clientbound::PING, |w| w.i32(nonce as i32)))
.unwrap();
let (id, body) = server.recv().unwrap();
assert_eq!(id, ids::play::serverbound::CHUNK_BATCH_RECEIVED);
let rate = Reader::new(&body).f32().unwrap();
if let Some(fixed) = ack_rate {
assert_eq!(rate, fixed);
} else if size == 0 {
assert_eq!(rate, 3.5, "vanilla initial rate must remain the default");
} else {
assert!(rate.is_finite() && rate > 0.0);
}
let (id, body) = server.recv().unwrap();
assert_eq!(
id,
ids::play::serverbound::PONG,
"exactly one ACK per finished batch"
);
assert_eq!(Reader::new(&body).i32().unwrap(), nonce as i32);
}
server.writer.shutdown();
handle.join().unwrap();
}
#[test]
fn fixed_64_ack_is_sent_once_per_finished_batch() {
check_chunk_acks(Some(64.0));
}
#[test]
fn vanilla_acks_remain_once_per_finished_batch() {
check_chunk_acks(None);
}
fn feed(entities: &mut Entities, packet: &Clientbound) {
let bytes = packet.encode();
let mut r = Reader::new(&bytes);
@@ -514,34 +514,6 @@ pub fn name_text(tag: &Tag) -> Text {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nested_fallback_references_stay_bounded() {
// 300 references to the argument, nested three deep: 27 million copies of "A" from a
// 4 KB component, which ran a client out of memory.
let bomb = |depth: usize, text: &str| {
let pattern = "%1$s".repeat(300);
let mut v = serde_json::json!({ "text": text });
for _ in 0..depth {
v = serde_json::json!({"translate": "x.unknown", "fallback": pattern, "with": [v]});
}
v
};
let started = std::time::Instant::now();
let text = to_text(&bomb(3, "A"), &mut |s| Text::literal(s));
assert!(text.spans.len() < 1 << 15, "{} spans", text.spans.len());
// One long argument referenced many times multiplies bytes, not spans.
let long = "B".repeat(64 << 10);
let text = to_text(&bomb(1, &long), &mut |s| Text::literal(s));
let bytes: usize = text.spans.iter().map(|s| s.text.len()).sum();
assert!(bytes <= 4 << 20, "{bytes} bytes");
assert!(started.elapsed() < std::time::Duration::from_secs(5));
// A real fallback still formats.
let ok = serde_json::json!({
"translate": "x.unknown", "fallback": "%1$s and %1$s", "with": [{"text": "A"}]
});
assert_eq!(to_text(&ok, &mut |s| Text::literal(s)).plain(), "A and A");
}
use crate::lang::{self, Language, ResourceSource};
struct Mem(std::collections::HashMap<String, Vec<u8>>);
+1 -19
View File
@@ -529,13 +529,10 @@ impl Team {
None => t,
}
}
/// `PlayerTeam.getFormattedDisplayName`: the display name in the team's
/// `displayNameStyle`, which its constructor makes the team's name on hover
/// (`ShowText`) and as insertion.
/// `PlayerTeam.getFormattedDisplayName`.
pub fn formatted_display_name(&self) -> Text {
let inner = hover_name(comp_text(&self.display_name), &self.name).with_style(&Style {
click: None,
insertion: Some(self.name.clone()),
..Style::default()
});
self.apply_color(bracketed(inner))
@@ -1414,21 +1411,6 @@ fn finish(node: &mut serde_json::Map<String, Value>, extra: Vec<Comp>) -> Comp {
#[cfg(test)]
mod tests {
use super::*;
/// #14279: `PlayerTeam`'s `displayNameStyle` puts the team's name on hover
/// and as insertion; `getFormattedDisplayName` brackets the name in it.
#[test]
fn a_teams_formatted_name_inserts_the_team_name() {
let team = Team::new("reds");
let c = team.formatted_display_name().component();
assert_eq!(c["translate"], "chat.square_brackets");
let inner = &c["with"][0];
assert_eq!(inner["text"], "reds");
assert_eq!(inner["insertion"], "reds");
assert_eq!(inner["hover_event"]["action"], "show_text");
// `{"text":"reds"}` or its short form `"reds"`: the same component.
let value = &inner["hover_event"]["value"];
assert!(value == "reds" || value["text"] == "reds", "{value}");
}
fn board() -> Scoreboard {
let mut sb = Scoreboard::default();
+11 -69
View File
@@ -282,20 +282,17 @@ impl Text {
pub fn push(&mut self, other: impl Into<Text>) {
let other = other.into();
let sibling = other.component();
// The tree is appended to in place: cloning all of it on every append made a component
// with N siblings (a server's chat or MOTD "extra") cost N^2.
let root = match self.source.take() {
None if self.spans.is_empty() => std::sync::Arc::new(json!({ "text": "" })),
None => std::sync::Arc::new(self.component()),
Some(c) => c,
};
let mut root = if STYLE_KEYS.iter().any(|k| root.get(*k).is_some()) {
let c = std::sync::Arc::try_unwrap(root).unwrap_or_else(|c| (*c).clone());
std::sync::Arc::new(json!({ "text": "", "extra": [c] }))
let mut root = if self.spans.is_empty() && self.source.is_none() {
json!({ "text": "" })
} else {
root
let c = self.component();
if STYLE_KEYS.iter().any(|k| c.get(*k).is_some()) {
json!({ "text": "", "extra": [c] })
} else {
c
}
};
if let Value::Object(o) = std::sync::Arc::make_mut(&mut root) {
if let Value::Object(o) = &mut root {
match o.get_mut("extra") {
Some(Value::Array(a)) => a.push(sibling),
_ => {
@@ -303,7 +300,7 @@ impl Text {
}
}
}
self.source = Some(root);
self.source = Some(root.into());
self.spans.extend(other.spans);
}
/// Apply `style` underneath every span (MutableComponent.withStyle on a parent).
@@ -674,22 +671,11 @@ pub fn legacy_format(style: &Style, code: char, reset: &Style) -> Style {
s
}
/// The most spans one translation expands to. Vanilla's `TranslatableContents.decompose` keeps
/// references to its arguments, so its cost stays linear in the input; copying them here makes
/// nested `%1$s` templates multiply (`k` references nested `D` deep: `k^D` copies), and a server's
/// `fallback` template is any text it likes.
const MAX_TRANSLATION_SPANS: usize = 1 << 15;
/// The most text one translation expands to: one long argument referenced many times multiplies
/// bytes, not spans.
const MAX_TRANSLATION_BYTES: usize = 1 << 22;
/// TranslatableContents.decomposeTemplate: `%s` takes the next argument,
/// `%n$s` a numbered one and `%%` is a percent sign. Malformed templates are
/// shown verbatim, and so is one that would expand past [`MAX_TRANSLATION_SPANS`] or
/// [`MAX_TRANSLATION_BYTES`].
/// shown verbatim.
pub fn format_translation(template: &str, args: &[Text]) -> Text {
let mut out = Text::default();
let mut bytes = 0usize;
let mut literal = String::new();
let mut next = 0usize;
let chars: Vec<char> = template.chars().collect();
@@ -739,14 +725,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
let Some(arg) = args.get(index) else {
return Text::literal(template);
};
bytes = bytes
.saturating_add(literal.len())
.saturating_add(arg.spans.iter().map(|s| s.text.len()).sum());
if out.spans.len().saturating_add(arg.spans.len()) >= MAX_TRANSLATION_SPANS
|| bytes > MAX_TRANSLATION_BYTES
{
return Text::literal(template);
}
if !literal.is_empty() {
out.spans.push(Span {
text: std::mem::take(&mut literal),
@@ -772,42 +750,6 @@ pub fn format_translation(template: &str, args: &[Text]) -> Text {
mod tests {
use super::*;
#[test]
fn appending_builds_the_same_tree_in_linear_time() {
// A styled text is wrapped once, so its style doesn't reach what follows; a plain one
// takes the siblings itself; a copy taken before an append keeps its own tree.
let mut styled = Text::literal("a").with_style(&Style {
bold: Some(true),
..Style::default()
});
styled.push(Text::literal("b"));
let before = styled.clone();
styled.push(Text::literal("c"));
assert_eq!(
styled.component(),
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}, {"text": "c"}]})
);
assert_eq!(
before.component(),
json!({"text": "", "extra": [{"text": "a", "bold": true}, {"text": "b"}]})
);
let mut plain = Text::default();
plain.push(Text::literal("x"));
plain.push(Text::literal("y"));
assert_eq!(
plain.component(),
json!({"text": "", "extra": [{"text": "x"}, {"text": "y"}]})
);
// 20 000 siblings, as a server's chat may carry: 76 s before, as each append cloned the
// whole tree.
let started = std::time::Instant::now();
let mut long = Text::default();
for _ in 0..20_000 {
long.push(Text::literal("a"));
}
assert_eq!(long.spans.len(), 20_000);
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
#[test]
fn click_events_serialize_and_inherit_as_one_style_field() {
for (event, json) in [
(
+2 -58
View File
@@ -6,12 +6,7 @@ use horizon_world::{
worldgen::{tags::TagDefinitions, terrain::TerrainBlockStates},
};
use serde_json::Value;
use std::{
collections::BTreeSet,
io,
path::Path,
sync::{Arc, Mutex},
};
use std::{collections::BTreeSet, io, path::Path, sync::Arc};
pub const MATERIALS: [&str; 6] = ["bedrock", "dirt", "stone", "sand", "oak_planks", "bricks"];
#[derive(Clone, Copy)]
@@ -187,23 +182,6 @@ static GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
}
/// Every block registry [`Assets::load`] has handed out, one per distinct
/// content. Callers keep `&'static` references, so a registry is never freed;
/// loading equal data again (tests load the same root once per fixture)
/// returns the one already leaked instead of leaking another (about 11 MB for
/// vanilla).
static REGISTRIES: Mutex<Vec<&'static BlockRegistry>> = Mutex::new(Vec::new());
/// `registry` leaked, or the equal one leaked before it.
fn intern_registry(registry: BlockRegistry) -> &'static BlockRegistry {
let mut leaked = REGISTRIES.lock().unwrap_or_else(|e| e.into_inner());
if let Some(&same) = leaked.iter().find(|&&known| *known == registry) {
return same;
}
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
leaked.push(registry);
registry
}
impl Assets {
pub fn load(root: &Path) -> Result<Self> {
let manifest: Value = horizon_world::json_file::read(&root.join("horizon-resources.json"))?;
@@ -278,7 +256,7 @@ impl Assets {
// Chunks hold every state of this registry: content mods can take it
// past vanilla's 16-bit global palette.
crate::chunk::set_block_states(registry.len());
let registry = intern_registry(registry);
let registry: &'static BlockRegistry = Box::leak(Box::new(registry));
Ok(Self {
registry,
properties,
@@ -342,16 +320,6 @@ impl RenderAssets {
}
None => crate::model_library::ParsedModels::default(),
};
// Connected-texture rules find their faces by sprite layer: what they match by name keeps a
// layer of its own, decided before the models insert their textures.
let ctm_rules = if draws {
horizon_optifine::ctm::load(root)
} else {
Vec::new()
};
sprites.keep_apart(crate::connected::ConnectedTextures::matched_sprites(
&ctm_rules,
));
let mut models = if draws {
let _step = crate::spans::startup::step("core.models_bake");
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
@@ -391,7 +359,6 @@ impl RenderAssets {
let mut found = Vec::new();
crate::connected::ConnectedTextures::load(
root,
ctm_rules,
registry,
&mut sprites,
|state, face| {
@@ -529,29 +496,6 @@ mod tests {
));
}
#[test]
fn equal_registries_are_leaked_once() {
use horizon_world::registry::BlockRegistry;
let registry = |extra: &str| {
let facts = format!(
r#"{{"defaults":{{"minecraft:air":0,"fixture:{extra}":1}},"states":[
["air",true,false,"","minecraft:air"],
["{extra}",false,false,"","fixture:{extra}"]
]}}"#
);
BlockRegistry::from_facts_json(facts.as_bytes()).unwrap()
};
let first = super::intern_registry(registry("interned_once"));
let again = super::intern_registry(registry("interned_once"));
let other = super::intern_registry(registry("interned_other"));
assert!(std::ptr::eq(first, again), "equal content leaked twice");
assert!(!std::ptr::eq(first, other), "different content shared");
assert_eq!(
other.default_state("fixture:interned_other"),
Some(horizon_world::registry::BlockStateId(1))
);
}
#[test]
fn sprite_pixels_come_back_the_same_once_packed() {
// Two layers of 16x16 RGBA with some structure, as sprites have.
+1 -10
View File
@@ -1974,11 +1974,7 @@ impl ChatScreen {
return false;
}
let max = l.list.len().saturating_sub(self.layout.line_limit) as i32;
// Round instead of truncating: touchpad/PixelDelta produces fractional
// amounts and truncation drops movement in one direction (#14338).
l.offset = (l.offset as f64 - amount.clamp(-1.0, 1.0))
.round()
.clamp(0.0, max as f64) as usize;
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input. The
@@ -2090,11 +2086,6 @@ impl ChatScreen {
) {
let limit = l.list.len().min(self.layout.line_limit);
let [rx, ry, rw, rh] = l.rect;
// SuggestionsList.extractRenderState: the whole list asks for the
// pointing hand while the pointer is inside its rectangle.
if mouse.0 >= rx && mouse.0 < rx + rw && mouse.1 >= ry && mouse.1 < ry + rh {
crate::widgets::request_cursor(crate::widgets::Cursor::PointingHand);
}
let has_prev = l.offset > 0;
let has_next = l.list.len() > l.offset + limit;
let moved = l.last_mouse != mouse;
@@ -1066,32 +1066,3 @@ fn a_server_filter_mask_hides_or_masks_the_message() {
.all(|s| s.style.color.is_none())
);
}
/// `SuggestionsList.extractRenderState`: the list's rectangle asks for the
/// pointing hand; outside it the cursor stays the arrow.
#[test]
fn the_suggestion_list_asks_for_the_pointing_hand() {
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/");
let rect = screen
.suggestions
.as_ref()
.expect("the root commands are a suggestion list")
.rect;
let _ = crate::widgets::take_cursor();
screen.draw(&m, (rect[0] + 2, rect[1] + 2), 0, 0);
assert_eq!(
crate::widgets::take_cursor(),
crate::widgets::Cursor::PointingHand
);
let _ = crate::widgets::take_cursor();
screen.draw(&m, (rect[0] - 1, rect[1] + 2), 0, 0);
assert_eq!(crate::widgets::take_cursor(), crate::widgets::Cursor::Arrow);
}
+3 -48
View File
@@ -124,25 +124,15 @@ fn sprite_name(resource: &str) -> String {
}
impl ConnectedTextures {
/// The sprites `rules` match by name (`matchTiles`, `connectTiles`), for
/// [`Sprites::keep_apart`]: before the models insert theirs.
pub fn matched_sprites(rules: &[Rule]) -> Vec<String> {
(rules.iter())
.flat_map(|r| r.match_tiles.iter().chain(&r.connect_tiles))
.map(|t| sprite_name(t))
.collect()
}
/// `rules` (`ctm::load` under `root`), with their tiles inserted into `sprites` (before it
/// finishes). `face_layer` answers a block state's sprite on a side, for rules that connect
/// by tile.
/// The rules under `root`, with their tiles inserted into `sprites` (before it finishes).
/// `face_layer` answers a block state's sprite on a side, for rules that connect by tile.
pub fn load(
root: &Path,
mut rules: Vec<Rule>,
registry: &BlockRegistry,
sprites: &mut Sprites,
mut face_layer: impl FnMut(BlockStateId, Face) -> Option<u32>,
) -> Self {
let mut rules = ctm::load(root);
// Rules with a custom name load no tiles: section meshes see no block entity.
rules.retain(|r| r.name.is_none());
// Rule indices are `u16`.
@@ -1280,41 +1270,6 @@ mod tests {
assert!(!ctm.stacked_pane_hides(3, 3, post));
}
#[test]
fn rules_name_the_sprites_they_match() {
// `matchTiles` among the textures and beside the file (a chain onto another rule's
// tile), and `connectTiles`: each as the sprite name the textures and tiles are inserted
// under.
let dir = "assets/minecraft/optifine/ctm/pack/crops";
let rule = |file: &str, text: &str| {
Rule::parse(
&format!("{dir}/{file}"),
&horizon_optifine::properties::Properties::parse(text),
)
.unwrap()
.unwrap()
};
let rules = [
rule(
"1.properties",
"matchTiles=./19.png\nmethod=random\ntiles=5-6",
),
rule(
"2.properties",
"matchTiles=textures/block/extra/placeholder.png\nmethod=overlay\n\
connectTiles=textures/block/stone.png\ntiles=0-16",
),
];
assert_eq!(
ConnectedTextures::matched_sprites(&rules),
[
format!("{dir}/19.png"),
"minecraft:block/extra/placeholder".to_owned(),
"minecraft:block/stone".to_owned(),
]
);
}
#[test]
fn sprite_names() {
assert_eq!(
@@ -12,8 +12,6 @@ use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
mod noise;
pub struct Environment {
system: System,
/// What the packs' expressions read; the frame sets it before sampling.
@@ -561,9 +559,13 @@ impl<'a> BlockHost<'a> {
}
}
/// The noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static noise::PerlinSimplex {
noise::builtin()
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
std::sync::OnceLock::new();
NOISE.get_or_init(|| {
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
})
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
@@ -613,7 +615,7 @@ impl horizon_polytone::expr::Host for BlockHost<'_> {
(V::Object(2), "noise") => {
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(polytone_noise().get(x, y))
num(f64::from(polytone_noise().get2(x, y)))
}
_ => Err(format!("{name} is not available to block colormaps")),
}
@@ -1,116 +0,0 @@
//! Polytone's `r.noise(x, y)`: 1.18-1.21's `PerlinSimplexNoise` over simplex noises, which 26.3 no
//! longer has. Measured on the real mod (Polytone 26.3-6.8.6) with a pack whose grass colormap encodes
//! the noise: the built-in noise is seed 0 with octaves `{1}`, read with the noise offsets
//! (`getValue(x, y, true)`), and a `polytone/noises` definition with seed 0 or 1 and octaves `{0}`,
//! `{1}` or `{0, 1}` matches this construction too.
use horizon_core::noise::SimplexNoise;
use horizon_core::random::{LegacyRandom, RandomSource};
/// `PerlinSimplexNoise(random, octaves)`: one simplex noise per octave, the highest octave first.
pub(crate) struct PerlinSimplex {
levels: Vec<Option<SimplexNoise>>,
input_factor: f64,
value_factor: f64,
}
impl PerlinSimplex {
/// The construction of `PerlinSimplexNoise(new LegacyRandomSource(seed), octaves)`: octave 0 takes
/// the first noise of `seed`'s random; the positive octaves come from a random seeded by that
/// noise's value at its own offsets, the negative ones from `seed`'s random after it.
pub(crate) fn new(seed: i64, octaves: &[i32]) -> Self {
let mut random = LegacyRandom::new(seed);
let lowest = octaves.iter().copied().min().unwrap_or(0);
let highest = octaves.iter().copied().max().unwrap_or(0);
let count = (highest - lowest + 1) as usize;
let first = SimplexNoise::new(&mut random);
let top = highest;
let wanted = |index: i32| octaves.contains(&(top - index));
let mut levels: Vec<Option<SimplexNoise>> = (0..count).map(|_| None).collect();
for index in top + 1..count as i32 {
if index >= 0 && wanted(index) {
levels[index as usize] = Some(SimplexNoise::new(&mut random));
} else {
random.consume_count(262);
}
}
if highest > 0 {
// `(long)(getValue(xo, yo, zo) * 9.223372E18F)`: the noise at its own offsets, which
// 26.3's get3 reads at (0, 0, 0). The float constant is 2^63.
let value = first.get3_f64(0.0, 0.0, 0.0) * 9.223_372e18_f32 as f64;
let mut upper = LegacyRandom::new(value as i64);
for index in (0..top).rev() {
if (index as usize) < count && wanted(index) {
levels[index as usize] = Some(SimplexNoise::new(&mut upper));
} else {
upper.consume_count(262);
}
}
}
if (0..count as i32).contains(&top) && octaves.contains(&0) {
levels[top as usize] = Some(first);
}
Self {
levels,
input_factor: 2f64.powi(highest),
value_factor: 1.0 / (2f64.powi(count as i32) - 1.0),
}
}
/// `getValue(x, y, true)`: each octave read with its offsets, which 26.3's simplex adds itself.
pub(crate) fn get(&self, x: f64, y: f64) -> f64 {
let (mut input, mut value, mut sum) = (self.input_factor, self.value_factor, 0.0);
for level in &self.levels {
if let Some(noise) = level {
sum += noise.get2_f64(x * input, y * input) * value;
}
input /= 2.0;
value *= 2.0;
}
sum
}
}
/// The built-in `r.noise(x, y)`: seed 0, octaves `{1}`.
pub(crate) fn builtin() -> &'static PerlinSimplex {
static NOISE: std::sync::OnceLock<PerlinSimplex> = std::sync::OnceLock::new();
NOISE.get_or_init(|| PerlinSimplex::new(0, &[1]))
}
#[cfg(test)]
mod tests {
use super::*;
/// `r.noise(x/8, z/8)` read on the real mod (Polytone 26.3-6.8.6): a grass colormap whose pixel
/// encodes the value, decoded to about 0.01 (polytone-work/rnoise in the measurement notes).
#[test]
fn the_builtin_noise_reads_what_the_mod_draws() {
for (x, z, mod_value) in [
(0, 10, -0.28373),
(3, 10, -0.14683),
(10, 0, 0.31833),
(24, 14, -0.55941),
(40, 25, -0.48358),
(7, 3, -0.68202),
] {
let value = builtin().get(f64::from(x) / 8.0, f64::from(z) / 8.0);
assert!(
(value - mod_value).abs() < 0.02,
"({x}, {z}): {value} against {mod_value}"
);
}
}
/// The octaves add as `PerlinSimplexNoise`'s do: `{0, 1}` is a third of `{1}` and two thirds of
/// `{0}` (measured: 0.067 = 0.33 x -0.284 + 0.67 x 0.247 at (0, 10) / 8).
#[test]
fn two_octaves_weigh_a_third_and_two_thirds() {
let both = PerlinSimplex::new(0, &[0, 1]);
let (low, high) = (PerlinSimplex::new(0, &[0]), PerlinSimplex::new(0, &[1]));
for (x, y) in [(0.0, 1.25), (2.5, -3.0), (17.1, 4.4)] {
let mixed = high.get(x, y) / 3.0 + low.get(x, y) * 2.0 / 3.0;
assert!((both.get(x, y) - mixed).abs() < 1e-12);
}
assert!((both.get(0.0, 1.25) - 0.067).abs() < 0.02);
}
}
+101 -190
View File
@@ -60,7 +60,6 @@ mod entity_outline;
mod fluid;
#[cfg(test)]
mod inter_stage_tests;
mod section_fade;
mod terrain_model;
#[cfg(test)]
pub(crate) mod test_gpu;
@@ -75,10 +74,7 @@ pub use entity_batch::{
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
mark_oit,
};
use section_fade::{
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
section_visibility,
};
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
pub use view::{View, ViewSet};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
@@ -194,6 +190,7 @@ mod cull;
mod dx12_compiler;
mod end_sky;
pub mod entity_skin;
pub mod external;
mod flat;
mod frame_gen;
mod gpu_culling;
@@ -370,6 +367,62 @@ fn pack_terrain_camera(camera: DVec3) -> DVec3 {
let whole = camera.floor();
whole + ((camera - whole) * 65536.0).round() / 65536.0
}
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
struct FadeInputs<'a> {
now: Instant,
fade_ms: u64,
uploaded: &'a FxHashMap<SectionKey, Instant>,
retiring: &'a crate::fast_hash::FxHashSet<SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
/// A new value for `Renderer::block_entity_revision`.
fn next_block_entity_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -1125,8 +1178,6 @@ pub struct Renderer {
/// Sections upload their solid quads in the compact layout (`mesher::packed`): the solid
/// passes read the arena themselves (`flat`) and no shader pack reads it as vertices.
packed_quads: bool,
/// The compact layout is allowed (`HORIZON_PACKED_QUADS`, `set_packed_layout`).
packed_on: bool,
/// The visibility buffer for flat frames' solid terrain (`visbuffer`,
/// `HORIZON_VISBUFFER=1`).
visbuf: Option<visbuffer::Visbuffer>,
@@ -1369,8 +1420,10 @@ pub struct Renderer {
/// Per-frame scratch, kept to avoid allocating every frame.
visible: Vec<resident::Visible>,
visible_lods: Vec<lod::Visible>,
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
shadow_visible: Vec<resident::Visible>,
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
external: Option<Box<dyn external::ExternalPipeline>>,
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
origins: Vec<[f32; 4]>,
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
@@ -4904,8 +4957,8 @@ impl Renderer {
}
None => (solid, solid_opaque, solid_cutout, solid_mirror),
};
// The solid quads in the compact layout where every pass reads it; the first world
// frames settle it for the picked pack (`follow_packed_layout`).
// The solid quads in the compact layout, while no shader pack reads the arena
// (`follow_packed_layout`).
let packed_quads = flat.is_some() && !shader_pack && packed_wanted();
packed::pack_quads(packed_quads);
// A native mod's world warp builds its pipelines with these layouts once it is given one.
@@ -5073,7 +5126,6 @@ impl Renderer {
world_warps,
flat_on: flat.is_some(),
packed_quads,
packed_on: packed_wanted(),
cull_on: cull.is_some(),
sides_on: true,
gpu_culling: None,
@@ -5242,6 +5294,7 @@ impl Renderer {
solid_runs: page_runs::PageRuns::default(),
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
external: None,
block_entities: Default::default(),
block_entity_section_revisions: Default::default(),
block_entity_membership_revisions: Default::default(),
@@ -5625,6 +5678,9 @@ impl Renderer {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
self.depth = Self::depth(&self.device, &self.config);
if let Some(pipeline) = &mut self.external {
pipeline.resize([width, height]);
}
}
/// Whether sections are drawn as one index list per frame (`flat`).
pub fn flat_draws(&self) -> bool {
@@ -6710,17 +6766,13 @@ impl Renderer {
flat.set_translucent_page_vertices(arena.page_vertices());
}
}
/// Uploads solid quads in the compact layout (`packed_quads`) unless a pack reads the
/// arena's vertices as `TerrainVertex`es: Horizon's own packs' stages, and a `shaders/`
/// pack's terrain and shadow programs outside flat draws (flat ones unpack the records,
/// `pack_atlas::PULL_FLAT`). Leaving it drops every section and far tile for the game to
/// mesh again, as `follow_translucent_layout` does.
/// Uploads solid quads in the compact layout (`packed_quads`) while no shader pack runs:
/// a pack's terrain and shadow programs, and Horizon's own packs' stages, read the
/// arena's vertices as `TerrainVertex`es. Leaving it drops every section and far tile
/// for the game to mesh again, as `follow_translucent_layout` does.
fn follow_packed_layout(&mut self) {
let pack = self.pack_post.as_ref().and_then(|p| p.1.as_ref()).is_some();
let want = self.flat.is_some()
&& (!pack || self.pack_terrain_flat)
&& self.native.is_none()
&& self.packed_on;
let want = self.flat.is_some() && !pack && self.native.is_none() && packed_wanted();
if want == self.packed_quads {
return;
}
@@ -6731,10 +6783,6 @@ impl Renderer {
let waiting = self.hold.take_all();
self.place_held(waiting);
}
log::info!(
"Horizon: compact solid terrain {}",
if want { "on" } else { "off" }
);
self.packed_quads = want;
packed::pack_quads(want);
let laid_out = self.sections.values().any(|s| s.packed)
@@ -6744,17 +6792,6 @@ impl Renderer {
self.clear_sections(self.layout);
}
}
/// Allows the compact layout of solid quads or not, for A/B checks: every section and far
/// tile is meshed again in the layout that follows. Replies whether sections now use it.
pub fn set_packed_layout(&mut self, on: bool) -> bool {
if on != self.packed_on {
self.packed_on = on;
self.follow_packed_layout();
self.sections_dropped = true;
self.clear_sections(self.layout);
}
self.packed_quads
}
/// Whether every section and far tile was dropped since the last call, to be
/// laid out again (`follow_translucent_layout`): the game meshes them again,
/// as for a new renderer.
@@ -7197,8 +7234,7 @@ impl Renderer {
.as_ref()
.map_or([1, 1], |t| [t.width(), t.height()]),
terrain: self.pack_atlas.as_ref().map(|_| pack_post::Geometry {
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
buffers: if flat { &[] } else { &buffers },
buffers: if flat { &buffers[..1] } else { &buffers },
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
depth_write: true,
atlas: None,
@@ -7261,12 +7297,19 @@ impl Renderer {
}
Ok(report)
}
/// Sets the external shader pipeline world frames call (`external`), or none.
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
self.external = pipeline;
}
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
pub fn reset_shader_pack_history(&mut self) {
// A frame still on the submit thread uses the targets and readbacks replaced
// here.
self.land_frame();
if let Some(pipeline) = &mut self.external {
pipeline.reset_history();
}
if let Some((_, Some(post))) = &mut self.pack_post {
post.reset_history(&self.device);
}
@@ -7368,8 +7411,7 @@ impl Renderer {
lightmap,
gui_size,
terrain: terrain.then(|| pack_post::Geometry {
// Flat terrain programs pull their vertices (`pack_atlas::PULL_FLAT`).
buffers: if flat { &[] } else { &buffers },
buffers: if flat { &buffers[..1] } else { &buffers },
table: table.as_ref(),
depth_write: true,
atlas: None,
@@ -7534,8 +7576,7 @@ impl Renderer {
);
horizon_shaders::GeometryInputs {
inputs,
// Flat terrain inputs pull their own vertex (`PULL_FLAT`, `gl_VertexIndex` → `hz_vertex_index`).
pull: String::new(),
pull: pack_atlas::PULL.to_owned(),
index: "hz_indices[@]".to_owned(),
}
});
@@ -8603,6 +8644,13 @@ impl Renderer {
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
// an Iris-format pack, only those whose shadow can fall into the camera's view.
let state = external::FrameState {
frame,
output: &output,
};
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
pipeline.begin_frame(&state);
}
let shadow_view = self
.pack_post
.as_ref()
@@ -8614,6 +8662,12 @@ impl Renderer {
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
})
.or_else(|| {
self.external
.as_ref()
.filter(|_| !frame.title)?
.shadow_request(&state)
});
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
let fade_ms = fade_millis(frame.chunk_fade);
@@ -12745,61 +12799,6 @@ impl Cull for Frusta {
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
}
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The block entities of `sections` whose box, a block wider on every side, comes within the
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
fn block_entities_within_reach<'a>(
@@ -12822,17 +12821,6 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -12956,21 +12944,6 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
Some((a.inverse() * -b).as_vec3())
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
fn shadow_runs(
sides: &Sides,
view: &pack_post::ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
@@ -12997,68 +12970,6 @@ fn facing_runs_from(
None => sides.every(part, draw),
}
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
// Widen x/y before extracting the planes: this includes every such shifted
@@ -15280,8 +15191,8 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
});
}
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
/// the brightness slider's, as Paper White, and its `MaxLuminance`, the display's or a calibration
/// profile's, as Peak Brightness), kept within the settings' ranges. The player's values win.
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -15357,7 +15268,7 @@ mod shadow_face_tests {
}
/// The layers (faces) of the quads the shadow pass draws of `solid`.
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
let mut solid = block([7.0, 7.0, 7.0]);
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
let mut layers = Vec::new();
@@ -15370,8 +15281,8 @@ mod shadow_face_tests {
layers
}
fn view(every_face: bool) -> pack_post::ShadowView {
pack_post::ShadowView {
fn view(every_face: bool) -> external::ShadowView {
external::ShadowView {
cull: Mat4::IDENTITY,
light: Vec3::Y,
distance: None,
@@ -15585,7 +15496,7 @@ mod shadow_caster_tests {
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
let view = external::ShadowView {
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
light: Vec3::Y,
distance: None,
@@ -0,0 +1,214 @@
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
//!
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
//! has one implementer in this workspace, carries no stability promise and is never exposed to
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
//! frame, and a pipeline draws into the frame it gives.
//!
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
//! visibility.
use glam::{Mat4, Vec3, Vec4};
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
/// What a world frame gives an external pipeline at its points: the frame's own values, and
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
pub struct FrameState<'a> {
pub frame: &'a Frame<'a>,
pub output: &'a EffectiveOutput,
}
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
/// title frames). Every method has a default that leaves vanilla's way in place.
pub trait ExternalPipeline: Send {
/// A world frame starts, before culling.
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
/// The shadow pass this frame wants; `None` draws none.
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
None
}
/// The window's drawable size changed.
fn resize(&mut self, _size: [u32; 2]) {}
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
fn reset_history(&mut self) {}
}
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
pub(super) fn shadow_runs(
sides: &Sides,
view: &ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
@@ -548,107 +548,27 @@ impl PackAtlas {
}
}
/// GLSL: terrain.wgsl's `section_corner` over the arena page (`hz_vertices`), by quad: a
/// vertex's words as `TerrainVertex` holds them (position, then UV, layer with light, colour),
/// from vertices below `PACKED_BASE` or a compact record (`mesher/packed.rs`) past it. Records
/// give back their quad's vertices bit for bit, so packs read either layout alike.
/// `hz_arena_corner`: a vertex's position and UV words; `hz_record_corner`: corner `k`'s of the
/// record `lo`, `hi`; `hz_record_rest`: its layer-with-light and colour words.
const CORNER_FN: &str = "void hz_arena_corner(uint hz_av, out uvec3 hz_p, out uint hz_uv) {\n \
uint hz_w = hz_av * 6u;\n \
hz_p = uvec3(hz_vertices[hz_w], hz_vertices[hz_w + 1u], hz_vertices[hz_w + 2u]);\n \
hz_uv = hz_vertices[hz_w + 3u];\n\
}\n\
uint hz_corner_offset(uint hz_rel, uint hz_side, uint hz_du, uint hz_dw) {\n \
return hz_rel == 0u ? hz_side : (hz_rel == 1u ? hz_du : hz_dw);\n\
}\n\
void hz_record_corner(uvec3 hz_lo, uvec3 hz_hi, uint hz_k, out uvec3 hz_p, out uint hz_uv) {\n \
uint hz_o = (hz_hi.z >> 12u) & 0x3ffffu;\n \
uvec3 hz_block = uvec3(hz_o & 15u, (hz_o >> 4u) & 15u, (hz_o >> 8u) & 15u);\n \
uint hz_axis = (hz_o >> 12u) & 3u;\n \
uint hz_side = (hz_o >> 14u) & 1u;\n \
uint hz_step = (((hz_o >> 15u) & 3u) + (((hz_o >> 17u) & 1u) == 1u ? 4u - hz_k : hz_k)) & 3u;\n \
uint hz_du = (hz_step == 1u || hz_step == 2u) ? 1u : 0u;\n \
uint hz_dw = hz_step >= 2u ? 1u : 0u;\n \
uvec3 hz_pos = hz_block + uvec3(hz_corner_offset((3u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
hz_corner_offset((4u - hz_axis) % 3u, hz_side, hz_du, hz_dw),\n \
hz_corner_offset((5u - hz_axis) % 3u, hz_side, hz_du, hz_dw));\n \
uint hz_sel = hz_lo.z >> 24u;\n \
uint hz_u = (hz_lo.z >> (6u * ((hz_sel >> (2u * hz_k)) & 1u))) & 63u;\n \
uint hz_v = (hz_lo.z >> (12u + 6u * ((hz_sel >> (2u * hz_k + 1u)) & 1u))) & 63u;\n \
hz_p = floatBitsToUint(vec3(hz_pos));\n \
hz_uv = (hz_u << 10u) | (hz_v << 26u);\n\
}\n\
uvec2 hz_record_rest(uvec3 hz_lo, uvec3 hz_hi, uint hz_k) {\n \
uint hz_l = hz_k == 0u ? hz_hi.x >> 16u : (hz_k == 1u ? hz_hi.y : (hz_k == 2u ? hz_hi.y >> 12u : hz_hi.z));\n \
hz_l &= 0xfffu;\n \
uint hz_pl = ((hz_l & 63u) << 2u) | (((hz_l >> 6u) & 63u) << 10u);\n \
uint hz_g = (hz_lo.y >> (8u * hz_k)) & 255u;\n \
uint hz_t = hz_lo.x;\n \
uint hz_code = hz_k == 1u ? (hz_hi.x >> 28u) & 7u : (hz_k == 2u ? (hz_hi.y >> 24u) & 7u\n \
: (hz_k == 3u ? (hz_hi.y >> 27u) & 7u : 0u));\n \
uint hz_alpha = hz_code == 0u ? hz_t & 0xff000000u : (hz_code * 51u) << 24u;\n \
uint hz_c = (hz_g * ((hz_t >> 16u) & 255u) / 255u) | ((hz_g * ((hz_t >> 8u) & 255u) / 255u) << 8u)\n \
| ((hz_g * (hz_t & 255u) / 255u) << 16u) | hz_alpha;\n \
return uvec2((hz_hi.x & 0xffffu) | (hz_pl << 16u), hz_c);\n\
}\n";
/// GLSL: a flat-drawn vertex's inputs, and its quad's four corners' positions (`hz_cp0`..) and
/// UV words (`hz_uv0`..), pulled from the arena page as terrain.wgsl's `vs_section_flat` pulls
/// them (`CORNER_FN`): a record is read once for all four, and only the vertex's own light and
/// colour are worked out. `hz_slot`: the slot its block is found by (`hz_block_slots`, 256 a
/// block). The colour as the vertex fetch converts a Unorm8x4 (terrain.wgsl's `unorm8x4`).
const PULL_FLAT: &str = " uint hz_qv = uint(gl_VertexIndex);\n \
uint hz_own = hz_qv & 3u;\n \
uint hz_av = hz_qv & ~3u;\n \
uint hz_slot = hz_qv;\n \
bool hz_rec = false;\n \
uvec3 hz_lo = uvec3(0u);\n \
uvec3 hz_hi = uvec3(0u);\n \
if (hz_av >= 0x10000000u) {\n \
hz_slot = (hz_av - 0x10000000u) >> 2u;\n \
uint hz_rw = hz_slot * 6u;\n \
hz_lo = uvec3(hz_vertices[hz_rw], hz_vertices[hz_rw + 1u], hz_vertices[hz_rw + 2u]);\n \
hz_hi = uvec3(hz_vertices[hz_rw + 3u], hz_vertices[hz_rw + 4u], hz_vertices[hz_rw + 5u]);\n \
hz_rec = (hz_hi.z & 0x80000000u) == 0u;\n \
hz_av = hz_slot + hz_lo.x;\n \
}\n \
uvec3 hz_cp0; uvec3 hz_cp1; uvec3 hz_cp2; uvec3 hz_cp3;\n \
uint hz_uv0; uint hz_uv1; uint hz_uv2; uint hz_uv3;\n \
uvec2 hz_rest;\n \
if (hz_rec) {\n \
hz_record_corner(hz_lo, hz_hi, 0u, hz_cp0, hz_uv0);\n \
hz_record_corner(hz_lo, hz_hi, 1u, hz_cp1, hz_uv1);\n \
hz_record_corner(hz_lo, hz_hi, 2u, hz_cp2, hz_uv2);\n \
hz_record_corner(hz_lo, hz_hi, 3u, hz_cp3, hz_uv3);\n \
hz_rest = hz_record_rest(hz_lo, hz_hi, hz_own);\n \
} else {\n \
hz_arena_corner(hz_av, hz_cp0, hz_uv0);\n \
hz_arena_corner(hz_av + 1u, hz_cp1, hz_uv1);\n \
hz_arena_corner(hz_av + 2u, hz_cp2, hz_uv2);\n \
hz_arena_corner(hz_av + 3u, hz_cp3, hz_uv3);\n \
uint hz_ow = (hz_av + hz_own) * 6u;\n \
hz_rest = uvec2(hz_vertices[hz_ow + 4u], hz_vertices[hz_ow + 5u]);\n \
}\n \
uvec3 hz_vp = hz_own == 0u ? hz_cp0 : (hz_own == 1u ? hz_cp1 : (hz_own == 2u ? hz_cp2 : hz_cp3));\n \
uvec3 hz_vr = uvec3(hz_own == 0u ? hz_uv0 : (hz_own == 1u ? hz_uv1 : (hz_own == 2u ? hz_uv2 : hz_uv3)), hz_rest);\n \
hz_v_pos = uintBitsToFloat(hz_vp);\n \
hz_v_uv = uvec2(hz_vr.x & 65535u, hz_vr.x >> 16u);\n \
hz_v_layer = hz_vr.y & 65535u;\n \
hz_v_light = uvec2((hz_vr.y >> 16u) & 255u, hz_vr.y >> 24u);\n \
hz_v_color = vec4(float(hz_vr.z & 255u), float((hz_vr.z >> 8u) & 255u), float((hz_vr.z >> 16u) & 255u),\n \
float(hz_vr.z >> 24u)) * (1.0 / 255.0);\n";
/// A `TerrainVertex` of flat draws' arena page (`hz_vertices`, six words each) into the vertex
/// inputs, for vertex `hz_vertex_index`.
pub const PULL: &str = " uint hz_pw = hz_vertex_index * 6u;\n \
hz_v_pos = uintBitsToFloat(uvec3(hz_vertices[hz_pw], hz_vertices[hz_pw + 1u], hz_vertices[hz_pw + 2u]));\n \
hz_v_uv = uvec2(hz_vertices[hz_pw + 3u] & 65535u, hz_vertices[hz_pw + 3u] >> 16u);\n \
hz_v_layer = hz_vertices[hz_pw + 4u] & 65535u;\n \
uint hz_pl = hz_vertices[hz_pw + 4u] >> 16u;\n \
hz_v_light = uvec2(hz_pl & 255u, hz_pl >> 8u);\n \
hz_v_color = unpackUnorm4x8(hz_vertices[hz_pw + 5u]);\n";
/// GLSL: the face normal and tangent of a flat-drawn vertex, from its quad's first three corners
/// (`PULL_FLAT`'s corners: four consecutive vertex indices, in either layout).
const FACE: &str = " vec3 hz_p0 = uintBitsToFloat(hz_cp0);\n \
vec3 hz_p1 = uintBitsToFloat(hz_cp1);\n \
vec3 hz_p2 = uintBitsToFloat(hz_cp2);\n \
uvec3 hz_w = uvec3(hz_uv0, hz_uv1, hz_uv2);\n \
/// (quads are four consecutive vertices of the page; position words 0-2, UV word 3).
const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
vec3 hz_p0 = uintBitsToFloat(uvec3(hz_vertices[hz_q], hz_vertices[hz_q + 1u], hz_vertices[hz_q + 2u]));\n \
vec3 hz_p1 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 6u], hz_vertices[hz_q + 7u], hz_vertices[hz_q + 8u]));\n \
vec3 hz_p2 = uintBitsToFloat(uvec3(hz_vertices[hz_q + 12u], hz_vertices[hz_q + 13u], hz_vertices[hz_q + 14u]));\n \
uvec3 hz_w = uvec3(hz_vertices[hz_q + 3u], hz_vertices[hz_q + 9u], hz_vertices[hz_q + 15u]);\n \
vec2 hz_t0 = vec2(hz_w.x & 65535u, hz_w.x >> 16u);\n \
vec2 hz_d1 = vec2(hz_w.y & 65535u, hz_w.y >> 16u) - hz_t0;\n \
vec2 hz_d2 = vec2(hz_w.z & 65535u, hz_w.z >> 16u) - hz_t0;\n \
uint hz_w3 = hz_uv3;\n \
uint hz_w3 = hz_vertices[hz_q + 21u];\n \
vec2 hz_t3 = vec2(hz_w3 & 65535u, hz_w3 >> 16u);\n \
hz_mid = (clamp(hz_t0 * hz_uv_scale, 0.0, 1.0) + clamp((hz_t0 + hz_d1) * hz_uv_scale, 0.0, 1.0)\n \
+ clamp((hz_t0 + hz_d2) * hz_uv_scale, 0.0, 1.0) + clamp(hz_t3 * hz_uv_scale, 0.0, 1.0)) * 0.25;\n \
@@ -700,19 +620,11 @@ impl PackAtlas {
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
pub fn terrain_inputs(&self, flat: bool) -> horizon_shaders::GbuffersInputs {
// Flat draws pull their vertices from the arena page (`PULL_FLAT`), which may hold
// solid quads in the compact layout; others read them as vertex attributes.
let mut declarations = if flat {
String::from(
"vec3 hz_v_pos; uvec2 hz_v_uv; uint hz_v_layer; vec4 hz_v_color; uvec2 hz_v_light;\n",
)
} else {
String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
)
};
let mut declarations = String::from(
"layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in uvec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_layer;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec2 hz_v_light;\n",
);
let origin = if flat {
declarations.push_str(
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
@@ -725,8 +637,7 @@ impl PackAtlas {
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n\
layout(set = 3, binding = 2) readonly buffer HzTerrainTables { int hz_tables[]; };\n",
);
declarations.push_str(CORNER_FN);
"hz_slot_origins[hz_block_slots[hz_slot >> 8u]]"
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]"
} else {
declarations.push_str("layout(location = 5) in vec4 hz_v_origin;\n");
"hz_v_origin"
@@ -789,7 +700,6 @@ impl PackAtlas {
);
let mut body = body;
if flat {
body.insert_str(0, PULL_FLAT);
body.push_str(FACE);
body.push_str(
" uint hz_quad = uint(gl_VertexIndex) / 4u;\n \
@@ -125,7 +125,7 @@ fn terrain_fade_reaches_pack_pixels_through_real_instances() {
terrain: Some(atlas.terrain_inputs(flat)),
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
inputs: atlas.terrain_inputs(true),
pull: String::new(),
pull: super::PULL.to_owned(),
index: "@".to_owned(),
}),
..Default::default()
@@ -245,135 +245,6 @@ fn terrain_vertices_go_down_to_the_32768th_below_them() {
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
/// A shader pack's flat terrain inputs read a compact record (`mesher::packed`) as the four
/// vertices it holds: a whole tinted face with its own ambient occlusion per corner
/// (`separateAo`), two more, and a slab's top kept as its vertices (`packed::FULL`).
#[test]
fn compact_records_feed_a_pack_the_vertices_they_hold() {
use crate::mesher::packed;
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let mut atlas = PackAtlas::new(&device, &mut encoder, &[texture], 1, 1, 16).unwrap();
queue.submit([encoder.finish()]);
atlas.separate_ao = true;
let tint = 0x7F_B238u32;
let ao = [255u32, 153, 102, 204];
let face = |x: f32, y: f32| {
let pos = [
[x, y, 5.0],
[x, y, 6.0],
[x + 1.0, y, 6.0],
[x + 1.0, y, 5.0],
];
let uv = [[0, 0], [16 << 10, 0], [16 << 10, 16 << 10], [0, 16 << 10]];
(0..4).map(move |i| {
let [r, g, b] = [16, 8, 0].map(|s| 200 * (tint >> s & 255) / 255);
TerrainVertex {
pos: pos[i],
uv: uv[i],
layer: 0x8000,
light: 0xF0 | 0x40 << 8,
color: r | g << 8 | b << 16 | ao[i] << 24,
}
})
};
let solid: Vec<_> = [(3.0, 16.0), (5.0, 16.0), (7.0, 16.0), (3.0, 8.5)]
.into_iter()
.flat_map(|(x, y)| face(x, y))
.collect();
let records = packed::layout(&solid, &[tint; 4]);
assert_eq!(
packed::packed_quads(&records, 4),
3,
"whole faces pack, the slab doesn't"
);
let page = bytemuck::cast_slice(&records);
let packed_range = packed::PACKED_BASE..packed::PACKED_BASE + 16;
let origin = f32::from_bits((1. / UV_STEPS).to_bits());
let mut failures = Vec::new();
for expression in [
"gl_Vertex",
"gl_Color",
"vec4(gl_MultiTexCoord0.xy, gl_MultiTexCoord1.xy)",
"vec4(gl_Normal, 0.0)",
"at_tangent",
"at_midBlock",
"vec4(mc_midTexCoord, mc_Entity.xy)",
] {
for shadow in [false, true] {
let flags = [true, shadow];
let wide = sample_probe(&device, &queue, &atlas, &solid, origin, flags, expression);
let compact = sample_page(
&device,
&queue,
&atlas,
page,
packed_range.clone(),
origin,
flags,
expression,
);
let bits = |v: &[[f32; 4]]| v.iter().flatten().map(|f| f.to_bits()).collect::<Vec<_>>();
if bits(&wide) != bits(&compact) {
failures.push(format!(
"{expression} shadow={shadow}: {wide:?} != {compact:?}"
));
}
if expression == "gl_Color" {
for (corner, want) in ao.iter().enumerate() {
let got = compact[corner][3];
if (got - *want as f32 / 255.0).abs() > 1e-6 {
failures.push(format!("corner {corner}'s alpha {got} != {want}/255"));
}
}
}
}
}
// A pack's frames keep the translucent quads in the section arena after the solid range: three
// records and no `FULL` quad, then a water quad, whose corners must read as its own.
let mut page: Vec<[u32; 6]> = packed::layout(&solid[..12], &[tint; 3]);
let water: Vec<_> = face(9.0, 15.0).collect();
let at = page.len() as u32;
page.extend(water.iter().map(|v| bytemuck::cast::<_, [u32; 6]>(*v)));
for expression in ["vec4(gl_Normal, 0.0)", "at_tangent", "at_midBlock"] {
let flags = [true, false];
let wide = sample_probe(&device, &queue, &atlas, &water, origin, flags, expression);
let page = bytemuck::cast_slice(&page);
let after = sample_page(
&device,
&queue,
&atlas,
page,
at..at + 4,
origin,
flags,
expression,
);
if wide != after {
failures.push(format!("water {expression}: {wide:?} != {after:?}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
@@ -400,38 +271,18 @@ fn sample_probe(
atlas: &PackAtlas,
vertices: &[TerrainVertex],
scale: f32,
flags: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let page = bytemuck::cast_slice(vertices);
let drawn = 0..vertices.len() as u32;
sample_page(device, queue, atlas, page, drawn, scale, flags, expression)
}
/// `sample_probe` of the vertex indices `drawn` of an arena page holding `page`.
#[allow(clippy::too_many_arguments)]
fn sample_page(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
page: &[u8],
drawn: std::ops::Range<u32>,
scale: f32,
[flat, shadow]: [bool; 2],
expression: &str,
) -> Vec<[f32; 4]> {
let count = drawn.len() as u32;
let pp = |code| horizon_shaders::Preprocessed {
code,
..Default::default()
};
let vertex = pp(format!(
"attribute vec2 mc_midTexCoord;\nattribute vec4 at_tangent;\n\
attribute vec4 at_midBlock;\nattribute vec4 mc_Entity;\nvarying vec4 probe;\nvoid main() {{\n\
"attribute vec2 mc_midTexCoord;\nvarying vec4 probe;\nvoid main() {{\n\
probe = {expression};\n\
gl_Position = vec4((float(gl_VertexID - {}) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
drawn.start,
2. / count as f32,
gl_Position = vec4((float(gl_VertexID) + 0.5) * {} - 1.0, 0.0, 0.0, 1.0);\n}}",
2. / vertices.len() as f32,
));
// Integer attachments retain the float bits without requiring float-render-target
// capabilities on the adapter that the test runner picks.
@@ -518,8 +369,7 @@ fn sample_page(
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
// Flat draws pull their vertices from the page (`PULL_FLAT`), as in the game.
buffers: if flat { &[] } else { &vertex_layouts[..] },
buffers: &vertex_layouts[..if flat { 1 } else { 2 }],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::PointList,
@@ -558,7 +408,7 @@ fn sample_page(
);
let ids = buffer(bytemuck::bytes_of(&[0u32; 2]), wgpu::BufferUsages::STORAGE);
let data = buffer(
page,
bytemuck::cast_slice(vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let rects = buffer(
@@ -594,7 +444,7 @@ fn sample_page(
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: count,
width: vertices.len() as u32,
height: 1,
depth_or_array_layers: 1,
},
@@ -607,7 +457,7 @@ fn sample_page(
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(count) * 16,
size: vertices.len() as u64 * 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
@@ -631,11 +481,11 @@ fn sample_page(
for (k, group) in groups.iter().enumerate() {
pass.set_bind_group(k as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(0, data.slice(..));
pass.set_vertex_buffer(1, origin.slice(..));
}
pass.draw(drawn, 0..1);
pass.draw(0..vertices.len() as u32, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
@@ -1201,31 +1201,7 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
Option<(u32, &wgpu::BindGroupLayout)>,
) + 'a;
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub(super) struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
pub(super) use super::external::ShadowView;
/// An arena page's key, native vertices and optional per-quad source records.
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
@@ -1137,10 +1137,6 @@ fn components(name: &str) -> usize {
| "endFlashPosition" => 3,
"eyeBrightness" | "eyeBrightnessSmooth" | "atlasSize" => 2,
"entityColor" => 4,
// Horizon's own vec4s, a dynamic light and half a boat's water patch box; their counts
// are ints.
n if n.starts_with("horizonDynamicLight") && n != "horizonDynamicLightCount" => 4,
n if n.starts_with("horizonWaterPatch") && n != "horizonWaterPatchCount" => 4,
n if n.starts_with("gbuffer") || n.starts_with("shadow") && n != "shadowAngle" => 16,
_ => 1,
}
@@ -1212,28 +1208,6 @@ mod tests {
(&matrix, "gbufferModelView", "mat4", true),
(&matrix, "gbufferModelView", "mat3", false),
(&Value::i(&[1]), GBUFFERS_FOG_MODE, "int", true),
// Horizon's own: a dynamic light and a water patch box half are vec4s, their counts
// ints.
(
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
"horizonDynamicLight0",
"vec4",
true,
),
(
&Value::f(&[1.0, 64.0, 2.0, 14.0]),
"horizonDynamicLight0",
"float",
false,
),
(
&Value::f(&[0.5, -1.0, 62.0, 62.5]),
"horizonWaterPatch0",
"vec4",
true,
),
(&Value::i(&[3]), "horizonDynamicLightCount", "int", true),
(&Value::i(&[1]), "horizonWaterPatchCount", "int", true),
] {
assert_eq!(value.read_as(name, ty), kept, "{ty} {name}");
}
@@ -1,60 +0,0 @@
//! How far through vanilla's chunk fade-in a section is, and the instance that carries it to `terrain.wgsl`.
use crate::fast_hash::FxHashMap;
use std::time::Instant;
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
pub(super) fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
pub(super) fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
pub(super) fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `chunk_visibility` at `now` of a section first uploaded at `uploaded`
/// (`Renderer::section_uploaded`; none: one that fades in over far terrain, or
/// whose column left), with the chunk fade-in `fade_ms`.
pub(super) fn section_visibility(uploaded: Option<&Instant>, fade_ms: u64, now: Instant) -> f32 {
uploaded.map_or(1.0, |&t| {
let elapsed = now.saturating_duration_since(t).as_millis() as u64;
chunk_visibility(elapsed, fade_ms)
})
}
/// What a section's fade depends on besides the section itself
/// (`SectionGpu::drawn_faded`): the time, the chunk fade-in time, the sections'
/// first uploads (`Renderer::section_uploaded`) and those held while far
/// terrain comes in (`Renderer::lod_retiring`).
#[derive(Clone, Copy)]
pub(super) struct FadeInputs<'a> {
pub(super) now: Instant,
pub(super) fade_ms: u64,
pub(super) uploaded: &'a FxHashMap<super::SectionKey, Instant>,
pub(super) retiring: &'a crate::fast_hash::FxHashSet<super::SectionKey>,
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
pub(super) const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
+5 -6
View File
@@ -30,9 +30,8 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The peak is the system's own figure (DXGI's `MaxLuminance`): the display's, or a
/// calibration profile's (Windows HDR Calibration), which Horizon can't tell apart. Auto
/// uses it, and the HDR page offers calibration.
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
pub peak_guessed: bool,
}
@@ -41,7 +40,7 @@ pub struct Reported {
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but not where its peak comes from: the page offers calibration.
/// The system says, but only guesses the peak: the player calibrates.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
@@ -1356,7 +1355,7 @@ mod tests {
}
#[test]
fn hdr_setup_offers_calibration_where_the_peaks_source_is_unknown() {
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
@@ -1371,7 +1370,7 @@ mod tests {
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
// Auto still takes the reported peak, over the 1000-nit default.
// Auto still takes the guess, over the 1000-nit default.
assert_eq!(g.peak_nits(Some(windows)), 1000);
assert_eq!(g.paper_white_nits(Some(windows)), 400);
for (peak, paper_white) in [(800, 0), (0, 240)] {
+12 -70
View File
@@ -697,7 +697,7 @@ fn description(
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
"Your display's reported peak is often too high: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
@@ -705,7 +705,7 @@ fn description(
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
// A peak of unknown source isn't said to be set up; paper white is the player's own.
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
{
@@ -1219,13 +1219,10 @@ impl Item {
}
pub fn label(self, s: &Settings, out: &EffectiveOutput) -> Text {
match self {
// Far terrain renames the row; its value stays vanilla's `options.chunks`.
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => {
Text::option_name_value(
Text::literal(lang::get("Full Detail Distance")),
Text::tr("options.chunks", &[&s.render_distance]),
)
}
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
"Full Detail Distance",
lang::tr("%s chunks", &[&s.render_distance]),
),
Item::Vanilla(o) => o.label(s),
Item::Horizon(h) => h.label(s, out),
Item::Extra(e) => Text::option_name_value(
@@ -1321,11 +1318,7 @@ impl Item {
/// Whether a setting is on the screen now: Upscaling only while TAA is, which does it.
fn shown(item: Item, s: &Settings) -> bool {
match item {
Item::Horizon(H::Upscaling) => s.horizon.taa,
Item::Extra(crate::extras::Item::Fullbright) => !s.fullbright_held,
_ => true,
}
item != Item::Horizon(H::Upscaling) || s.horizon.taa
}
/// A control's label as its name and its value ("Render Distance" and "12 chunks").
fn parts(item: Item, s: &Settings, out: &EffectiveOutput) -> (String, Option<String>) {
@@ -1437,7 +1430,7 @@ impl Screen for GraphicsScreen {
g::gpu_culling_offer(),
g::reduced_far_detail_suggested(),
);
shell::stamp_of(&(read(s), s.fullbright_held, offers))
shell::stamp_of(&(read(s), offers))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<Page, Item>> {
let sheet = |(page, groups): (Page, Vec<(&'static str, Vec<Setting>)>)| Sheet {
@@ -1997,31 +1990,6 @@ mod tests {
assert_eq!(key(&mut ui, &mut s, K::Escape), [Output::Done]);
}
/// On another's server Fullbright's row is gone and the screen draws again; its choice is
/// kept for the next world that allows it (#3823).
#[test]
fn fullbright_row_is_hidden_while_held_and_keeps_its_choice() {
let mut s = Settings::default();
s.extras.fullbright = true;
let screen = GraphicsScreen::default();
let listed = |s: &Settings, item| {
let sheets = screen.sheets(s);
let groups = sheets.into_iter().flat_map(|sheet| sheet.groups);
groups.flat_map(|group| group.rows).any(|r| r.id == item)
};
let fullbright = Item::Extra(crate::extras::Item::Fullbright);
let lights = Item::Extra(crate::extras::Item::DynamicLights);
assert!(listed(&s, fullbright));
let free = screen.stamp(&s);
s.fullbright_held = true;
assert!(!listed(&s, fullbright));
assert!(listed(&s, lights));
assert_ne!(screen.stamp(&s), free);
assert!(s.extras.fullbright);
s.fullbright_held = false;
assert!(listed(&s, fullbright));
}
/// The strip says what a row does, what its chosen value does where that differs, and why a
/// greyed row can't be used; the search finds a row by what it does.
#[test]
@@ -2142,36 +2110,10 @@ mod tests {
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
s.horizon.lod_distance = 12;
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
}
/// With far terrain on, Render Distance reads "Full Detail Distance" but keeps vanilla's
/// `options.chunks` value: it was Horizon's "%s chunks", so "12 Chunks" became "12 chunks"
/// (#14880).
#[test]
fn full_detail_distance_keeps_the_vanilla_chunk_count() {
struct Vanilla;
impl lang::ResourceSource for Vanilla {
fn read(&self, path: &str) -> Option<Vec<u8>> {
(path == "assets/minecraft/lang/en_us.json").then(|| {
serde_json::to_vec(&serde_json::json!({
"options.renderDistance": "Render Distance",
"options.chunks": "%s Chunks",
"options.generic_value": "%s: %s"
}))
.unwrap()
})
}
}
let mut s = Settings::default();
let near = s.render_distance;
let render = Item::Vanilla(Opt::RenderDistance);
let ((off, on), _) = lang::scoped(lang::Language::load(&[&Vanilla], "en_us"), || {
let off = render.label(&s, &NONE).plain();
s.horizon.lod_distance = 256;
(off, render.label(&s, &NONE).plain())
});
assert_eq!(off, format!("Render Distance: {near} Chunks"));
assert_eq!(on, format!("Full Detail Distance: {near} Chunks"));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s, &NONE).plain(),
format!("Full Detail Distance: {near} chunks")
);
}
#[test]
@@ -164,7 +164,7 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak may be the display's or a calibration profile's.
/// (Wayland), and Windows', whose peak is the display's own guess.
#[test]
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
@@ -200,7 +200,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
[
(
calibrate.into(),
"Auto uses the peak Windows reports. Calibrate if highlights look clipped."
"Your display's reported peak is often too high: calibrate to set it up."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
@@ -219,7 +219,7 @@ fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto has no known source on Windows: only Paper White is said to be set up there.
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
@@ -348,30 +348,3 @@ fn undo_never_restores_a_held_hdr_start() {
screen.undo(&mut s, &opened);
assert_eq!(s.horizon, opened.horizon);
}
/// The Calibrate row's descriptions fit the strip's two lines at vanilla's 427-wide GUI (a
/// 1080p window at scale 4 is wider): a longer one would end in "…".
#[test]
fn the_calibrate_descriptions_fit_the_strip() {
let atlas = crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent")).unwrap();
let mut guessed = offering(true, Some(true));
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let mut changed = Settings::default();
changed.horizon.hdr_peak = 800;
let item = Item::Horizon(H::HdrCalibrate);
let width = Columns::of(427.0, 240.0).dock[2] - 24.0;
for (s, out) in [
(Settings::default(), NONE),
(Settings::default(), guessed),
(Settings::default(), offering(true, Some(true))),
(changed, guessed),
] {
let text = description(item, &s, &out, "-");
assert_ne!(text, "-");
let lines = atlas.font.split(&Text::literal(text.to_owned()), width);
assert!(lines.len() <= 2, "{text}");
}
}
@@ -13,9 +13,8 @@
//! - word 1: each corner's grey level before the tint;
//! - word 2: the UV rectangle in 1/32 of the sprite (u0, u1, v0, v1, 6 bits each) and, per
//! corner, which edge of it its u and v take;
//! - word 3: the layer, corner 0's light (sky and block light over 4, 6 bits each), and
//! corner 1's alpha code (`ALPHA_STEP` times it; 0: word 0's alpha);
//! - word 4: corners 1 and 2's light, and corners 2 and 3's alpha codes;
//! - word 3: the layer, and corner 0's light (sky and block light over 4, 6 bits each);
//! - word 4: corners 1 and 2's light;
//! - word 5: corner 3's light, the face (block x, y, z, the axis it faces along, which side
//! of the block, and where and which way its corners go round), and `FULL` clear.
use super::TerrainVertex;
@@ -28,8 +27,6 @@ pub const WHITE: u32 = 0xFF_FFFF;
/// Word 5's bit of a quad kept as four vertices; word 0 is then the slots from the record
/// to its first vertex.
pub const FULL: u32 = 1 << 31;
/// The step of a corner's own alpha (words 3 and 4's codes): 255 / 5.
pub const ALPHA_STEP: u32 = 51;
/// The draws' base vertex of a compact mesh: `PACKED_BASE + 4 * slot + corner` is a corner of
/// the quad whose record is at `slot`; below it, vertex indices name vertices.
pub const PACKED_BASE: u32 = 1 << 28;
@@ -136,24 +133,12 @@ pub fn pack_quad(quad: &[TerrainVertex; 4], tint: u32) -> Option<[u32; 6]> {
for (i, v) in quad.iter().enumerate() {
grays |= gray_of(v.color, tint)? << (8 * i);
}
// Corners 1 to 3's alpha (a shader pack's `separateAo`: each corner's ambient occlusion,
// multiples of 51 on a whole face): `ALPHA_STEP` times the code, 0 for corner 0's.
let mut codes = [0u32; 3];
for (code, v) in codes.iter_mut().zip(&quad[1..]) {
let a = v.color >> 24;
if v.color & 0xFF00_0000 != alpha {
if a == 0 || a % ALPHA_STEP != 0 {
return None;
}
*code = a / ALPHA_STEP;
}
}
let words = [
tint | alpha,
grays,
rect | select << 24,
u32::from(quad[0].layer) | light[0] << 16 | codes[0] << 28,
light[1] | light[2] << 12 | codes[1] << 24 | codes[2] << 27,
u32::from(quad[0].layer) | light[0] << 16,
light[1] | light[2] << 12,
light[3] | orient << 12,
];
let exact = (0..4).all(|c| {
@@ -205,18 +190,12 @@ pub fn packed_vertex(words: &[u32; 6], corner: usize) -> TerrainVertex {
let light = ((light & 63) << 2 | (light >> 6 & 63) << 10) as u16;
let g = w1 >> (8 * i) & 255;
let [r, gr, b] = channels(w0).map(|t| g * t / 255);
let code = [0, w3 >> 28 & 7, w4 >> 24 & 7, w4 >> 27 & 7][corner];
let alpha = if code == 0 {
w0 & 0xFF00_0000
} else {
(code * ALPHA_STEP) << 24
};
TerrainVertex {
pos: pos.map(|c| c as f32),
uv,
layer: (w3 & 0xFFFF) as u16,
light,
color: r | gr << 8 | b << 16 | alpha,
color: r | gr << 8 | b << 16 | (w0 & 0xFF00_0000),
}
}
@@ -268,10 +247,11 @@ pub fn place_into(
let n = quads.len();
let packed = records.iter().filter(|r| r.is_some()).count();
let full = n - packed;
// Whole quads of slots: what follows (a quad's own vertices, the translucent ones after
// the solid range) then starts a quad, as a shader pack's vertex stage reads its quad's
// corners from the vertex index (`pack_atlas::PULL_FLAT`).
let pad = n.next_multiple_of(4) - n;
let pad = if full > 0 {
n.next_multiple_of(4) - n
} else {
0
};
if n + pad + 4 * full >= 4 * n {
return;
}
@@ -354,22 +334,6 @@ mod tests {
}
}
#[test]
fn each_corners_ambient_occlusion_alpha_packs_on_its_steps() {
// A shader pack's `separateAo`: the alpha byte is each corner's occlusion.
let mut quad = top([0, 1, 2, 3]);
for (v, a) in quad.iter_mut().zip([153u32, 255, 51, 204]) {
v.color = v.color & 0x00FF_FFFF | a << 24;
}
let words = pack_quad(&quad, TINT).expect("occlusion on the 51 steps packs");
for (corner, v) in quad.iter().enumerate() {
assert!(same(&packed_vertex(&words, corner), v), "corner {corner}");
}
// A partial face's blended occlusion falls between the steps.
quad[2].color = quad[2].color & 0x00FF_FFFF | 140 << 24;
assert!(pack_quad(&quad, TINT).is_none());
}
#[test]
fn quads_off_the_grid_or_the_steps_keep_their_vertices() {
// Half a block up: a slab's top.
@@ -411,13 +375,4 @@ mod tests {
// Nothing smaller to gain: no layout at all.
assert!(layout(&odd, &[TINT]).is_empty());
}
#[test]
fn the_records_end_on_a_whole_quad_of_slots() {
// Five whole faces, every one a record: the translucent vertices after them start a quad.
let solid: Vec<TerrainVertex> = (0..5).flat_map(|_| top([0, 1, 2, 3])).collect();
let out = layout(&solid, &[TINT; 5]);
assert_eq!(packed_quads(&out, 5), 5);
assert_eq!(out.len(), 8);
}
}
+13 -256
View File
@@ -368,57 +368,6 @@ pub struct PackScreen {
browser_search: TextField,
/// The browser's search box has the keyboard.
browser_focus: bool,
/// Which atlas slot holds which icon, kept from one frame to the next
/// ([`PackScreen::bind_icons`]).
icon_slots: std::cell::RefCell<IconSlots>,
}
/// The atlas's pack icon slots and the icon each holds. The atlas keeps what is
/// written into a slot, so an icon is sent once, when it takes a slot, and not
/// again every frame it is drawn.
#[derive(Clone, Debug, Default)]
struct IconSlots {
/// The atlas the pixels went into ([`HudAtlas::revision`]); a new atlas holds none of them.
revision: u64,
/// Frames bound so far: an icon drawn in this one keeps its slot.
frame: u64,
/// Least recently drawn first: the icon's texture key, its slot, its pixels, and the
/// frame it was last drawn in.
held: Vec<(String, usize, Arc<[u8]>, u64)>,
}
impl IconSlots {
/// Starts a frame on `atlas`; an atlas that is not the one the slots were filled in empties them.
fn begin(&mut self, atlas: &HudAtlas) {
if self.revision != atlas.revision {
self.held.clear();
self.revision = atlas.revision;
}
self.frame += 1;
}
/// The slot for the icon drawn under `texture`, and whether its pixels have to be sent. A new
/// icon takes a free slot, else the slot of the icon drawn longest ago; none when every slot
/// holds an icon drawn in this frame, and that icon keeps `unknown_pack`.
fn slot_for(&mut self, texture: &str, icon: &Arc<[u8]>) -> Option<(usize, bool)> {
if let Some(at) = self.held.iter().position(|h| h.0 == texture) {
let mut kept = self.held.remove(at);
let same = Arc::ptr_eq(&kept.2, icon);
kept.2 = icon.clone();
kept.3 = self.frame;
let slot = kept.1;
self.held.push(kept);
return Some((slot, !same));
}
let slot = match (0..PACK_ICON_SLOTS).find(|i| !self.held.iter().any(|h| h.1 == *i)) {
Some(free) => free,
None if self.held.first().is_some_and(|h| h.3 < self.frame) => self.held.remove(0).1,
None => return None,
};
self.held
.push((texture.to_owned(), slot, icon.clone(), self.frame));
Some((slot, true))
}
}
fn tr(key: &str) -> Text {
@@ -510,7 +459,6 @@ impl PackScreen {
field
},
browser_focus: false,
icon_slots: Default::default(),
changed: false,
};
s.reposition(m);
@@ -983,13 +931,10 @@ impl PackScreen {
self.model.commit()
}
/// Points the icon blits of `draws` at the atlas's pack icon slots and
/// writes the icons into them (vanilla registers a texture per pack). An
/// icon keeps its slot from one frame to the next and is sent when it takes
/// the slot, not every frame; the slot of the icon drawn longest ago is
/// taken first. Icons past the last slot in one frame keep `unknown_pack`.
/// writes the icons into them, one slot per icon drawn (vanilla registers a
/// texture per pack). Icons past the last slot keep `unknown_pack`.
pub fn bind_icons(&self, draws: &mut [Draw], atlas: &HudAtlas, patches: &mut Vec<Patch>) {
let mut slots = self.icon_slots.borrow_mut();
slots.begin(atlas);
let mut slots = 0..PACK_ICON_SLOTS;
for draw in draws {
let Draw::Blit { texture, .. } = draw else {
continue;
@@ -997,23 +942,20 @@ impl PackScreen {
let Some(icon) = self.icons.get(texture.as_str()) else {
continue;
};
let Some((slot, send)) = slots.slot_for(texture, icon) else {
continue;
let Some(slot) = slots.next() else {
return;
};
let sprite = format!("horizon/pack-icon{slot}");
let Some(rect) = atlas.sprites.get(&sprite) else {
slots.held.pop();
return;
};
if send {
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
}
patches.push(Patch {
x: (rect.u0 * atlas.width as f32).round() as u32,
y: (rect.v0 * atlas.height as f32).round() as u32,
width: PACK_ICON_SIZE,
height: PACK_ICON_SIZE,
rgba: icon.to_vec(),
});
*texture = sprite;
}
}
@@ -1676,12 +1618,6 @@ impl PackScreen {
let hover = hovered == Some(i)
&& d::inside(mouse, bx, by, OPTIONS_SIZE, OPTIONS_SIZE);
Self::draw_options_button(out, bx, by, hover);
// A control for a pad's crosshair to glide onto and its D-pad to hop to,
// while the list shows it whole.
let mark = crate::widgets::targets_mark();
let size = OPTIONS_SIZE as f32;
crate::widgets::target([bx as f32, by as f32, size, size]);
crate::widgets::clip_targets(mark, l.y as f32, l.bottom() as f32);
if hover {
tooltip = Some(tr("horizon.pack.optionsInfo"));
}
@@ -1809,14 +1745,9 @@ mod icon_tests {
.unwrap();
assert_eq!(id, "file/Configurable");
let (cx, cy) = (bx + OPTIONS_SIZE / 2, by + OPTIONS_SIZE / 2);
// Hovered, it explains itself; a pad's crosshair reaches it as a control.
// Hovered, it explains itself.
let draws = screen.draw(&m, (cx, cy), 0, 0);
assert!(draws.iter().any(|d| matches!(d, Draw::Tooltip { .. })));
crate::widgets::end_frame(crate::widgets::generation() + 1);
let size = OPTIONS_SIZE as f32;
let button = [bx as f32, by as f32, size, size];
let targets = crate::widgets::last_targets();
assert!(targets.iter().any(|t| t.rect == button), "{targets:?}");
assert_eq!(
screen.click(&m, cx as f64, cy as f64),
Action::Options("file/Configurable".into())
@@ -2139,177 +2070,3 @@ mod footer_label_tests {
assert_eq!(lang::code(), before, "the global language was left alone");
}
}
#[cfg(test)]
mod icon_slot_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// `count` packs with an icon each, the pixels of icon `i` all `i`.
fn packs(count: usize) -> Vec<PackInfo> {
let side = PACK_ICON_SIZE as usize;
(0..count)
.map(|i| PackInfo {
id: format!("file/pack{i:03}"),
title: Text::literal(format!("pack{i:03}")),
description: Text::literal(""),
description_style: Style::default(),
source: Source::BuiltIn,
compat: Compat::Compatible,
icon: Some(vec![i as u8; side * side * 4].into()),
required: false,
position: Position::Top,
fixed: false,
})
.collect()
}
/// One frame as `options_ui` draws it: the calls, then the icons bound. The icons drawn
/// (their texture keys before binding), the textures after, and the patches sent.
fn frame(screen: &PackScreen, atlas: &HudAtlas) -> (Vec<String>, Vec<String>, Vec<Patch>) {
let m = Measure(&atlas.font);
let mut draws = screen.draw(&m, (0, 0), 0, 0);
let drawn: Vec<String> = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if screen.icons.contains_key(texture) => {
Some(texture.clone())
}
_ => None,
})
.collect();
let mut patches = Vec::new();
screen.bind_icons(&mut draws, atlas, &mut patches);
let bound = draws
.iter()
.filter_map(|d| match d {
Draw::Blit { texture, .. } if texture.contains("pack-icon") => {
Some(texture.clone())
}
_ => None,
})
.collect();
(drawn, bound, patches)
}
/// Only the available list has packs: one row of 36 for each of `count` icons fits in a
/// window `height` high.
fn screen(m: &Measure, count: usize, height: i32) -> PackScreen {
PackScreen::new(
m,
tr("resourcePack.title"),
480,
height,
false,
packs(count),
&[],
)
}
/// Every frame sent every drawn icon again (a 128 by 128 RGBA copy each) into the same
/// atlas, which keeps what it is given: an icon is sent when it takes a slot, once.
#[test]
fn an_icon_is_sent_once_and_not_every_frame() {
let atlas = atlas();
let screen = screen(&Measure(&atlas.font), 5, 400);
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!((drawn.len(), bound.len(), patches.len()), (5, 5, 5));
for again in 0..3 {
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!(rebound, bound, "frame {again}");
assert_eq!(resent.len(), 0, "frame {again}");
}
}
/// More icons drawn in one frame than there are slots: the first 64 get one, the rest keep
/// `unknown_pack`, and the next frame does not move them or send anything.
#[test]
fn past_the_slots_in_one_frame_the_rest_keep_unknown_pack() {
let atlas = atlas();
let screen = screen(&Measure(&atlas.font), PACK_ICON_SLOTS + 6, 4000);
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!(
drawn.len(),
PACK_ICON_SLOTS + 6,
"the window shows every row"
);
assert_eq!(
(bound.len(), patches.len()),
(PACK_ICON_SLOTS, PACK_ICON_SLOTS)
);
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!((rebound, resent.len()), (bound, 0));
}
/// Scrolling through more packs than slots: every icon drawn gets a slot, each one is sent
/// once on the way down, and a frame that draws what the last one drew sends nothing.
#[test]
fn scrolling_sends_only_the_icons_that_come_into_view() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut screen = screen(&m, 70, 330);
let mut seen = std::collections::HashSet::new();
let mut sent = 0;
// 150 wheel steps of 18 are more than the 2520 high list.
for step in 0..150 {
let (drawn, bound, patches) = frame(&screen, &atlas);
assert_eq!(bound.len(), drawn.len(), "step {step}: none falls back");
let new = drawn.iter().filter(|t| seen.insert((*t).clone())).count();
assert_eq!(patches.len(), new, "step {step}");
sent += patches.len();
assert_eq!(frame(&screen, &atlas).2.len(), 0, "step {step}: same view");
screen.scroll(100.0, 200.0, -1.0);
}
assert_eq!((seen.len(), sent), (70, 70));
}
/// The slot of the icon drawn longest ago is the one taken, and the icon that left it is
/// sent again when it comes back.
#[test]
fn the_icon_drawn_longest_ago_gives_up_its_slot() {
let icons: Vec<Arc<[u8]>> = packs(PACK_ICON_SLOTS + 1)
.into_iter()
.map(|p| p.icon.unwrap())
.collect();
let mut slots = IconSlots::default();
let key = |i: usize| format!("k{i}");
slots.begin(&atlas());
for i in 0..PACK_ICON_SLOTS {
assert_eq!(slots.slot_for(&key(i), &icons[i]), Some((i, true)));
}
// The next frame draws icon 0 first, then a new one: slot 1, the oldest left, is taken.
slots.frame += 1;
assert_eq!(slots.slot_for(&key(0), &icons[0]), Some((0, false)));
assert_eq!(
slots.slot_for("new", &icons[PACK_ICON_SLOTS]),
Some((1, true))
);
// Icon 1 comes back: it is sent again, into the slot of the next oldest.
assert_eq!(slots.slot_for(&key(1), &icons[1]), Some((2, true)));
}
/// New pixels under the same key take the same slot and are sent; a new atlas holds none
/// of the icons, so all are sent again.
#[test]
fn changed_pixels_and_a_new_atlas_send_again() {
let atlas = atlas();
let mut screen = screen(&Measure(&atlas.font), 3, 400);
let (_, bound, patches) = frame(&screen, &atlas);
assert_eq!(patches.len(), 3);
let key = icon_texture("file/pack001");
screen
.icons
.insert(key, vec![200; patches[1].rgba.len()].into());
let (_, rebound, resent) = frame(&screen, &atlas);
assert_eq!(rebound, bound);
assert_eq!(resent.len(), 1);
assert_eq!((resent[0].x, resent[0].y), (patches[1].x, patches[1].y));
assert_eq!(resent[0].rgba[0], 200);
let other = self::atlas();
assert_ne!(other.revision, atlas.revision);
assert_eq!(frame(&screen, &other).2.len(), 3);
}
}
+64 -18
View File
@@ -31,10 +31,8 @@ mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
mod simple;
mod spawn;
pub use native::{ParticleController, ParticleUpdate};
use simple::{PARALLEL_MIN, parallel_threads, sprite_from_age_in, tick_simple};
/// `ParticleOptions` with the parameters of the option classes used here.
#[derive(Clone, Debug)]
@@ -1327,9 +1325,6 @@ pub struct ParticleEngine {
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, (u32, Option<ModBehaviour>)>,
set_sprites: Vec<Vec<u32>>,
/// How many live particles it takes to tick them on several threads (`tick_all`), and how many threads.
parallel_min: usize,
parallel_threads: usize,
/// Resource packs' Polytone custom particle types (#11486).
polytone: Option<std::sync::Arc<polytone::Registry>>,
/// Vanilla particle types a pack replaces with a custom one of the same id
@@ -1532,8 +1527,6 @@ impl ParticleEngine {
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
parallel_min: PARALLEL_MIN,
parallel_threads: parallel_threads(),
polytone: None,
polytone_overrides: HashMap::new(),
polytone_gpu: Vec::new(),
@@ -2128,7 +2121,23 @@ impl ParticleEngine {
}
/// `SingleQuadParticle.setSpriteFromAge`.
fn sprite_from_age(&self, p: &mut Particle) {
sprite_from_age_in(&self.set_sprites, p);
if p.removed {
return;
}
if let Some(set) = p.set {
// A sprite set's index can outlive the reload that rebuilt them.
let Some(sprites) = self.set_sprites.get(set as usize) else {
return;
};
let lifetime = p.lifetime.max(1);
let index = if p.frame_ticks > 0 {
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
} else {
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
.clamp(0, sprites.len() as i64 - 1)
};
p.sprite = Sprite::Index(sprites[index as usize]);
}
}
#[allow(clippy::too_many_lines)]
/// `ParticleProvider.createParticle`: provider draws use the engine's
@@ -3838,7 +3847,9 @@ impl ParticleEngine {
self.emitters = emitters;
let mut particles = std::mem::take(&mut self.particles);
self.spawned_in_tick = Some(Vec::new());
self.tick_all(&mut particles, level);
for p in &mut particles {
self.tick_particle(p, level);
}
particles.retain(|p| !p.removed);
self.particles = particles;
for (options, pos, velocity) in self.spawned_in_tick.take().unwrap_or_default() {
@@ -3943,15 +3954,7 @@ impl ParticleEngine {
#[allow(clippy::too_many_lines)]
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
match p.behaviour {
Behaviour::Base
| Behaviour::Mod { .. }
| Behaviour::Animated
| Behaviour::Snowflake
| Behaviour::Crit
| Behaviour::Spark { .. } => {
let simple = tick_simple(&self.set_sprites, p, level);
self.finish_simple(p, simple);
}
Behaviour::Base => p.base_tick(level),
Behaviour::Controlled { .. } => {
if let Some(mut controller) = p.controller.take() {
let update = controller.tick(p, level);
@@ -3970,6 +3973,23 @@ impl ParticleEngine {
p.remove();
}
}
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
self.sprite_from_age(p);
}
Behaviour::Animated => {
p.base_tick(level);
self.sprite_from_age(p);
}
Behaviour::Snowflake => {
p.base_tick(level);
self.sprite_from_age(p);
p.velocity[0] *= f64::from(0.95f32);
p.velocity[1] *= f64::from(0.9f32);
p.velocity[2] *= f64::from(0.95f32);
}
Behaviour::Lava => {
p.base_tick(level);
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
@@ -3980,6 +4000,11 @@ impl ParticleEngine {
);
}
}
Behaviour::Crit => {
p.base_tick(level);
p.rgba[1] *= 0.96;
p.rgba[2] *= 0.9;
}
Behaviour::Drip {
fluid,
stage,
@@ -4450,6 +4475,27 @@ impl ParticleEngine {
}
}
}
Behaviour::Spark {
fade,
trail,
twinkle,
} => {
p.base_tick(level);
self.sprite_from_age(p);
if p.age > p.lifetime / 2 {
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
if let Some(f) = fade {
for i in 0..3 {
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
}
}
}
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
child.age = child.lifetime / 2;
self.pending.push(child);
}
}
Behaviour::Spell { original_alpha } => {
p.base_tick(level);
self.sprite_from_age(p);
@@ -774,7 +774,7 @@ impl Host for Ctx<'_, '_> {
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(crate::environment::polytone_noise().get(x, y))
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
}
(id, _) if id >= BLOCKS => {
let at = *self
@@ -1,161 +0,0 @@
//! The particles that tick on their own (`tick_simple`): the behaviours that read and write only
//! their own particle, the world and the sprite sets, which `ParticleEngine::tick_all` runs on several
//! threads in a busy scene.
use super::{Behaviour, Particle, ParticleEngine, ParticleLevel, Sprite, mod_curve};
/// The live particles it takes before `tick_all` uses more than one thread: a scoped thread costs
/// tens of microseconds to start and join, and a simple particle about a tenth of one.
pub(super) const PARALLEL_MIN: usize = 2048;
/// Threads for ticking particles: half the machine's workers, at most four (the chunk workers and
/// the render thread need cores too); `HORIZON_WORKER_THREADS` caps them as it does every pool.
pub(super) fn parallel_threads() -> usize {
(horizon_threads::worker_count() / 2).clamp(1, 4)
}
pub(super) fn sprite_from_age_in(set_sprites: &[Vec<u32>], p: &mut Particle) {
if p.removed {
return;
}
if let Some(set) = p.set {
// A sprite set's index can outlive the reload that rebuilt them.
let Some(sprites) = set_sprites.get(set as usize) else {
return;
};
let lifetime = p.lifetime.max(1);
let index = if p.frame_ticks > 0 {
(p.age.max(0) as i64 / i64::from(p.frame_ticks)) % sprites.len() as i64
} else {
(p.age.max(0) as i64 * (sprites.len() as i64 - 1) / i64::from(lifetime))
.clamp(0, sprites.len() as i64 - 1)
};
p.sprite = Sprite::Index(sprites[index as usize]);
}
}
/// The tick of the behaviours that read and write only their own particle, the world and the engine's
/// sprite sets: no particle spawned, no sound, nothing drawn from the engine's random. Those are the
/// bulk of a busy scene (flames, smoke, poofs, dust, crits), and `ParticleEngine::tick` runs them on
/// several threads. Any other behaviour is left alone, for `ParticleEngine::tick_particle`.
pub(super) fn tick_simple(
set_sprites: &[Vec<u32>],
p: &mut Particle,
level: &ParticleLevel,
) -> Simple {
match p.behaviour {
Behaviour::Base => p.base_tick(level),
Behaviour::Mod { size, alpha } => {
p.base_tick(level);
p.size = mod_curve(size, p.age, p.lifetime);
p.rgba[3] = mod_curve(alpha, p.age, p.lifetime);
sprite_from_age_in(set_sprites, p);
}
Behaviour::Animated => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
}
Behaviour::Snowflake => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
p.velocity[0] *= f64::from(0.95f32);
p.velocity[1] *= f64::from(0.9f32);
p.velocity[2] *= f64::from(0.95f32);
}
Behaviour::Crit => {
p.base_tick(level);
p.rgba[1] *= 0.96;
p.rgba[2] *= 0.9;
}
Behaviour::Spark { fade, trail, .. } => {
p.base_tick(level);
sprite_from_age_in(set_sprites, p);
if p.age > p.lifetime / 2 {
p.rgba[3] = 1.0 - (p.age as f32 - (p.lifetime / 2) as f32) / p.lifetime as f32;
if let Some(f) = fade {
for i in 0..3 {
p.rgba[i] += (f[i] - p.rgba[i]) * 0.2;
}
}
}
if trail && p.age < p.lifetime / 2 && (p.age + p.lifetime) % 2 == 0 {
return Simple::SparkTrail;
}
}
_ => return Simple::No,
}
Simple::Done
}
/// The same world with a one-chunk lookup cache of its own, for a worker thread.
///
/// `ParticleLevel::cached_chunk` is a `Cell`, which a level shared between threads may not hold:
/// each thread gets a view with an empty cache, built here on the engine thread and moved in. A
/// cache they shared could not hold "the last chunk looked up" for any one of them in any case.
fn thread_view<'a>(level: &ParticleLevel<'a>) -> ParticleLevel<'a> {
ParticleLevel {
world: level.world,
facts: level.facts,
collision: level.collision,
player: level.player,
cached_chunk: std::cell::Cell::new(None),
}
}
/// What `tick_simple` did with a particle.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Simple {
/// Not one of its behaviours: the engine ticks it.
No,
/// Ticked completely.
Done,
/// Ticked, except that a firework spark's trail wants a child. Only the engine can make one:
/// it takes the next seed, so the children come in the order the sparks have.
SparkTrail,
}
impl ParticleEngine {
/// What `tick_simple` leaves to the engine: a spark's trail child.
pub(super) fn finish_simple(&mut self, p: &Particle, simple: Simple) {
if simple != Simple::SparkTrail {
return;
}
if let Behaviour::Spark { fade, twinkle, .. } = p.behaviour {
let mut child = self.spark(p.position, [0.0; 3], p.rgba, fade, false, twinkle);
child.age = child.lifetime / 2;
self.pending.push(child);
}
}
/// One tick of every live particle. The ones `tick_simple` covers are independent of each
/// other and of the engine, so with enough of them they tick on several threads; every other
/// one then ticks here, in the order it had, so the particles, the engine's random and what
/// is spawned come out as when all of them ticked one after another.
pub(super) fn tick_all(&mut self, particles: &mut [Particle], level: &ParticleLevel) {
if particles.is_empty() || particles.len() < self.parallel_min || self.parallel_threads < 2
{
for p in particles {
self.tick_particle(p, level);
}
return;
}
let sets = &self.set_sprites;
let per = particles.len().div_ceil(self.parallel_threads);
let mut handled = vec![Simple::No; particles.len()];
std::thread::scope(|scope| {
let run = |part: &mut [Particle], done: &mut [Simple], level: &ParticleLevel| {
for (p, done) in part.iter_mut().zip(done) {
*done = tick_simple(sets, p, level);
}
};
let mut parts = particles.chunks_mut(per).zip(handled.chunks_mut(per));
let own = parts.next();
for (part, done) in parts {
let view = thread_view(level);
scope.spawn(move || run(part, done, &view));
}
// This thread takes the first share, instead of waiting for the others.
if let Some((part, done)) = own {
run(part, done, level);
}
});
for (p, done) in particles.iter_mut().zip(handled) {
match done {
Simple::No => self.tick_particle(p, level),
simple => self.finish_simple(p, simple),
}
}
}
}
@@ -1,12 +1,8 @@
//! The particle stress scene's load on `ParticleEngine`, outside the game.
//!
//! - `parallel_tick_matches_serial_tick`: the engine ticking on several threads (`tick_all`) leaves
//! every particle, the engine's random and its sounds exactly as ticking them one after another.
//! - `particle_tick_cost` (`cargo test --profile fast -p horizon-client-render --lib
//! particle_tick_cost -- --ignored --nocapture`, prepared resources; `PARTICLE_BENCH_SECS` sets
//! the run, default 2): spawns what tools/stress_scenes.py's `particles` scene makes each tick
//! (flames, cosy smoke, end rods, TNT debris, rockets) over its stone floor and times the spawns
//! and the engine tick apart, once the engine holds as many particles as it will.
//! The particle stress scene's load on `ParticleEngine`, outside the game: `cargo test --profile fast
//! -p horizon-client-render --lib particle_tick_cost -- --ignored --nocapture` (prepared resources;
//! `PARTICLE_BENCH_SECS` sets the run, default 5). It spawns what tools/stress_scenes.py's `particles`
//! scene makes each tick (flames, cosy smoke, end rods, TNT debris) over its stone floor and times the
//! spawns and the engine tick apart, once the engine holds as many particles as it will.
use super::*;
use crate::{blocks::BlockFacts, chunk::Chunk, collisions, world::World};
use std::sync::Arc;
@@ -29,7 +25,6 @@ impl Lcg {
}
}
/// What to spawn each tick: a type, how many, around where, how far, how fast.
type Emitter = (ParticleType, usize, [f64; 3], [f64; 3], f64);
fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], rockets: usize) {
@@ -63,29 +58,9 @@ fn spawn_tick(engine: &mut ParticleEngine, rng: &mut Lcg, types: &[Emitter], roc
}
}
/// The scene's emitters, per tick: flames, cosy smoke, end rods, and TNT's debris (poof and smoke).
fn emitters() -> [Emitter; 5] {
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
[
(ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01),
(
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
),
(ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05),
(ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4),
(ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4),
]
}
/// Runs `f` over the stress scene's world (a stone floor at y 289 under the particles). Skipped
/// without prepared resources.
fn with_scene(f: impl FnOnce(&ParticleLevel)) {
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
@@ -113,127 +88,69 @@ fn with_scene(f: impl FnOnce(&ParticleLevel)) {
world.set(x, FLOOR_Y, z, floor).unwrap();
}
}
f(&ParticleLevel {
let level = ParticleLevel {
world: &world,
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
});
}
fn engine(parallel_min: usize, threads: usize) -> ParticleEngine {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
engine.parallel_min = parallel_min;
engine.parallel_threads = threads;
engine
}
/// Every live particle's fields, as text.
fn state(engine: &ParticleEngine) -> Vec<String> {
engine
.particles
.iter()
.map(|p| format!("{:?}", engine.snapshot(p)))
.collect()
}
#[test]
fn parallel_tick_matches_serial_tick() {
with_scene(|level| {
let types = emitters();
// One engine ticks every particle in turn; the other on three threads. Same seeds, same spawns.
let mut serial = engine(usize::MAX, 1);
let mut threaded = engine(0, 3);
for e in [&mut serial, &mut threaded] {
e.seed_randoms(7, 8, 9);
}
let (mut a, mut b) = (Lcg(99), Lcg(99));
for tick in 0..90 {
spawn_tick(&mut serial, &mut a, &types, 3);
spawn_tick(&mut threaded, &mut b, &types, 3);
serial.tick(level);
threaded.tick(level);
assert_eq!(serial.len(), threaded.len(), "tick {tick}");
if tick % 10 == 9 {
assert_eq!(state(&serial), state(&threaded), "tick {tick}");
}
}
assert!(serial.len() > 1000, "{} particles", serial.len());
assert_eq!(serial.sounds.len(), threaded.sounds.len());
assert_eq!(serial.next_randoms(), threaded.next_randoms());
});
}
/// Spawns and ticks `types` once the engine is full, for `secs`, and prints the cost per tick.
fn measure(
level: &ParticleLevel,
name: &str,
(types, rockets): (&[Emitter], usize),
(parallel_min, threads): (usize, usize),
secs: f64,
) {
let mut engine = engine(parallel_min, threads);
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, types, rockets);
engine.tick(level);
}
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
}
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:24} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
let tnt = [0.5, f64::from(FLOOR_Y + 24), 26.5];
// The scene's emitters, per tick, and TNT's debris (poof and smoke).
let flame: Emitter = (ty("flame"), 320, [fx, fy, fz], [8.0, 4.0, 8.0], 0.01);
let cosy: Emitter = (
ty("campfire_cosy_smoke"),
8,
[fx, fy, fz],
[8.0, 4.0, 8.0],
0.0,
);
}
#[test]
#[ignore = "a measurement, not a check"]
fn particle_tick_cost() {
with_scene(|level| {
let [flame, cosy, rod, poof, smoke] = emitters();
let all = [flame, cosy, rod, poof, smoke];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
let one = (usize::MAX, 1);
eprintln!("default threads: {}", parallel_threads());
for (name, types, rockets) in [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
] {
measure(level, name, (&types, rockets), one, secs);
let rod: Emitter = (ty("end_rod"), 80, [fx, fy, fz], [8.0, 4.0, 8.0], 0.05);
let poof: Emitter = (ty("poof"), 258, tnt, [3.0, 3.0, 3.0], 0.4);
let smoke: Emitter = (ty("smoke"), 238, tnt, [3.0, 3.0, 3.0], 0.4);
let cases: [(&str, Vec<Emitter>, usize); 7] = [
("flame 320/tick", vec![flame], 0),
("cosy smoke 8/tick", vec![cosy], 0),
("end rod 80/tick", vec![rod], 0),
("poof 258/tick", vec![poof], 0),
("smoke 238/tick", vec![smoke], 0),
("3 rockets/tick", vec![], 3),
("the whole scene", vec![flame, cosy, rod, poof, smoke], 3),
];
let secs: f64 = std::env::var("PARTICLE_BENCH_SECS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2.0);
for (name, types, rockets) in cases {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.camera = [0.5, 298.0, 0.5];
let mut rng = Lcg(1234);
for _ in 0..600 {
spawn_tick(&mut engine, &mut rng, &types, rockets);
engine.tick(&level);
}
measure(level, "whole scene, 1 thread", (&all, 3), one, secs);
for threads in [2, 3, 4, 6] {
measure(
level,
&format!("whole scene, {threads} threads"),
(&all, 3),
(PARALLEL_MIN, threads),
secs,
);
let (mut spawn, mut tick, mut ticks, mut alive) =
(Duration::ZERO, Duration::ZERO, 0u64, 0usize);
let end = Instant::now() + Duration::from_secs_f64(secs);
while Instant::now() < end {
let t = Instant::now();
spawn_tick(&mut engine, &mut rng, &types, rockets);
spawn += t.elapsed();
let t = Instant::now();
engine.tick(&level);
tick += t.elapsed();
alive += engine.len();
ticks += 1;
}
});
let per = |d: Duration| d.as_secs_f64() * 1000.0 / ticks as f64;
let alive = alive / ticks as usize;
eprintln!(
"{name:20} alive {alive:6} | per tick: spawn {:6.3} ms, engine tick {:6.3} ms | {:5.0} ns per particle",
per(spawn),
per(tick),
per(tick) * 1.0e6 / alive.max(1) as f64
);
}
}
@@ -203,9 +203,6 @@ pub struct Settings {
pub startup_api: crate::graphics::GraphicsApi,
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
pub video_modes: Vec<crate::window::VideoMode>,
/// Fullbright is held off: this window plays on another's server (set by the game, not
/// saved). The graphics screen hides its row; `extras.fullbright` keeps the choice.
pub fullbright_held: bool,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
pub controller: crate::gamepad::PadSettings,
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
@@ -296,7 +293,6 @@ impl Default for Settings {
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
fullbright_held: false,
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
@@ -518,7 +514,6 @@ impl Settings {
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
fullbright_held: false,
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
+7 -332
View File
@@ -932,56 +932,7 @@ pub struct Downloader {
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
/// How long a failed texture download waits before it is tried again.
retry_after: std::time::Duration,
/// The `states` keys, oldest first, so the oldest settled one can go ([`Bounds::states`]).
state_order: std::collections::VecDeque<(Kind, String)>,
/// The `resources` keys, oldest first ([`Bounds::resources`]).
resource_order: std::collections::VecDeque<String>,
/// Downloads started whose answer `state` has not taken yet: one thread each.
in_flight: usize,
/// Fetches a texture's body; the HTTP GET, but for a test.
fetch: fn(&str) -> Option<Vec<u8>>,
bounds: Bounds,
}
/// A download thread's answer to `state`. If the thread panics before giving it (a body that
/// breaks the decoder, say), dropping this answers "failed" instead: the download's place in
/// `in_flight` is given back and the texture is retried after the wait, where an answer that never
/// came kept the key `Pending` for good and, with the bound, the place taken for the session.
struct Answer {
send: std::sync::mpsc::Sender<((Kind, String), Option<Vec<u8>>)>,
key: Option<(Kind, String)>,
}
impl Answer {
fn give(&mut self, image: Option<Vec<u8>>) {
if let Some(key) = self.key.take() {
let _ = self.send.send((key, image));
}
}
}
impl Drop for Answer {
fn drop(&mut self) {
self.give(None);
}
}
/// What the downloader keeps and runs at most. Every texture URL a server (or a tab list of
/// hundreds of players) names used to add a `states` entry and a thread of its own, and every
/// `texture` id a `resources` entry, none of them ever dropped.
#[derive(Clone, Copy, Debug)]
struct Bounds {
/// Entries of `states`; the oldest settled (ready or failed) one is dropped past this. A
/// dropped texture is loaded again from the disk cache when it is asked for.
states: usize,
/// Entries of `resources`; the oldest is dropped past this, and read from the packs again.
resources: usize,
/// Downloads running at once. Past this `state` says `Pending` and starts nothing, so the
/// caller, which asks again every frame, starts it when one has finished.
downloads: usize,
}
const BOUNDS: Bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 16,
};
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
const MAX_SKIN_IMAGES: usize = 1024;
@@ -1013,11 +964,6 @@ impl Downloader {
resources: Default::default(),
failed: Default::default(),
retry_after: std::time::Duration::from_secs(60),
state_order: Default::default(),
resource_order: Default::default(),
in_flight: 0,
fetch: http_get,
bounds: BOUNDS,
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
@@ -1027,7 +973,6 @@ impl Downloader {
self.images.remove(&location);
self.image_order.retain(|l| *l != location);
}
self.resource_order.clear();
self.resource_root = root;
}
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
@@ -1057,47 +1002,12 @@ impl Downloader {
}
None => Download::Failed,
};
if self.resources.insert(location.to_owned(), state).is_none() {
self.resource_order.push_back(location.to_owned());
while self.resources.len() > self.bounds.resources
&& let Some(old) = self.resource_order.pop_front()
{
self.resources.remove(&old);
}
}
}
/// Forgets how `location` loaded, so `resource` reads it again.
fn forget_resource(&mut self, location: &str) {
if self.resources.remove(location).is_some() {
self.resource_order.retain(|l| l != location);
}
}
/// Records how `key` stands. A new key may push the oldest settled one out of `states`;
/// downloads running are never dropped, so their answer has a key to land on.
fn set_state(&mut self, key: (Kind, String), state: Download) {
if self.states.insert(key.clone(), state).is_some() {
return;
}
self.state_order.push_back(key);
while self.states.len() > self.bounds.states {
let Some(at) = self
.state_order
.iter()
.position(|k| self.states.get(k) != Some(&Download::Pending))
else {
break;
};
if let Some(old) = self.state_order.remove(at) {
self.states.remove(&old);
self.failed.remove(&old);
}
}
self.resources.insert(location.to_owned(), state);
}
/// How the download of `url` stands, starting it the first time. A
/// failure is permanent for `retry_after`, then tried again.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
self.in_flight = self.in_flight.saturating_sub(1);
let state = match image {
Some(rgba) => {
match key.0 {
@@ -1113,42 +1023,29 @@ impl Downloader {
Download::Failed
}
};
self.set_state(key, state);
self.states.insert(key, state);
}
let Some(hash) = texture_hash(url) else {
return Download::Failed;
};
let key = (kind, hash);
let full = self.in_flight >= self.bounds.downloads;
if let Some(state) = self.states.get(&key).copied() {
let due = self
.failed
.get(&key)
.is_none_or(|at| at.elapsed() >= self.retry_after);
// A failure that is due waits for a free download like a new texture does.
if state != Download::Failed || !due || full {
if state != Download::Failed || !due {
return state;
}
self.states.remove(&key);
self.failed.remove(&key);
self.state_order.retain(|k| *k != key);
}
if full {
// Not recorded: the next call asks again, and starts it once one has finished.
return Download::Pending;
}
self.in_flight += 1;
self.set_state(key.clone(), Download::Pending);
self.states.insert(key.clone(), Download::Pending);
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
let fetch = self.fetch;
std::thread::spawn(move || {
let mut answer = Answer {
send,
key: Some(key),
};
let image = Self::load_texture(&root, kind, &digest, &url, fetch);
answer.give(image);
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
let _ = send.send((key, image));
});
Download::Pending
}
@@ -1211,7 +1108,7 @@ impl Downloader {
if location.starts_with("patch/") {
let Some(image) = self.images.get(location).cloned() else {
// Dropped from memory: read from the packs again.
self.forget_resource(location);
self.resources.remove(location);
return false;
};
self.queue(location.to_owned(), image);
@@ -2028,225 +1925,3 @@ mod tests {
assert_eq!(served.load(Ordering::SeqCst), 2);
}
}
#[cfg(test)]
mod download_bound_tests {
use super::{Bounds, Download, Downloader, Kind};
use std::sync::atomic::{AtomicUsize, Ordering};
fn downloader(name: &str, bounds: Bounds, fetch: fn(&str) -> Option<Vec<u8>>) -> Downloader {
let root =
std::env::temp_dir().join(format!("horizon-test-skin-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut d = Downloader::new(root);
d.fetch = fetch;
d.bounds = bounds;
d
}
fn url(i: usize) -> String {
format!("https://textures.example/texture/skin{i:05}")
}
/// Asks again until the texture's download has settled.
fn wait(d: &mut Downloader, url: &str) -> Download {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
let state = d.state(Kind::Skin, url);
if state != Download::Pending || std::time::Instant::now() > deadline {
return state;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
}
static RUNNING: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);
/// A fetch that is slow enough for the others to pile up behind it, and counts how many run.
fn slow_failure(_: &str) -> Option<Vec<u8>> {
let now = RUNNING.fetch_add(1, Ordering::SeqCst) + 1;
PEAK.fetch_max(now, Ordering::SeqCst);
std::thread::sleep(std::time::Duration::from_millis(20));
RUNNING.fetch_sub(1, Ordering::SeqCst);
None
}
/// The same, not counted: tests run side by side, and the count is another test's.
fn sleepy_failure(_: &str) -> Option<Vec<u8>> {
std::thread::sleep(std::time::Duration::from_millis(20));
None
}
/// A tab list of 60 players names 60 textures at once: no more than the bound run together,
/// the ones that waited start as the others finish, and every one ends. Every frame asks again.
#[test]
fn downloads_run_a_few_at_a_time_and_the_late_ones_start_later() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 4,
};
let mut d = downloader("threads", bounds, slow_failure);
let urls: Vec<String> = (0..60).map(url).collect();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let mut first = true;
loop {
let states: Vec<Download> = urls.iter().map(|u| d.state(Kind::Skin, u)).collect();
if first {
// Asked all at once: four started, and the others were not even recorded.
assert_eq!(d.in_flight, 4);
assert_eq!(d.states.len(), 4);
assert!(states.iter().all(|s| *s == Download::Pending));
first = false;
}
if states.iter().all(|s| *s == Download::Failed) {
break;
}
assert!(
std::time::Instant::now() < deadline,
"{} still pending",
states.len()
);
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert!(
PEAK.load(Ordering::SeqCst) <= 4,
"{}",
PEAK.load(Ordering::SeqCst)
);
assert_eq!(d.states.len(), 60);
assert_eq!(d.in_flight, 0);
}
/// More textures than `states` holds: the map keeps to its bound, the oldest settled ones go
/// (and their failure time with them), and one that went is asked again as a new texture.
#[test]
fn the_states_keep_to_their_bound_and_drop_the_oldest() {
let bounds = Bounds {
states: 8,
resources: 4096,
downloads: 16,
};
let mut d = downloader("states", bounds, |_| None);
for i in 0..40 {
let u = url(i);
assert_eq!(wait(&mut d, &u), Download::Failed, "{i}");
assert!(d.states.len() <= 8 && d.state_order.len() == d.states.len());
assert!(d.failed.len() <= 8);
}
let kept: Vec<usize> = (0..40)
.filter(|i| {
d.states
.contains_key(&(Kind::Skin, super::texture_hash(&url(*i)).unwrap()))
})
.collect();
assert_eq!(kept, (32..40).collect::<Vec<_>>());
// The first one went: asked again, it starts again (and fails again).
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
assert_eq!(d.in_flight, 1);
}
/// A fetch that panics, as a decoder can on a body a server chose.
fn panicking(_: &str) -> Option<Vec<u8>> {
panic!("a fetch that panics, on purpose");
}
/// A thread that panics before it answers still gives its place back: with two places and six
/// textures whose fetch panics, every one settles as failed instead of the places staying
/// taken for good.
#[test]
fn a_download_that_panics_gives_its_place_back() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 2,
};
let mut d = downloader("panics", bounds, panicking);
for i in 0..6 {
assert_eq!(wait(&mut d, &url(i)), Download::Failed, "{i}");
}
assert_eq!(d.in_flight, 0);
assert_eq!(d.states.len(), 6);
}
/// A fetch long enough for a test to ask between its start and its end.
fn slowish_failure(_: &str) -> Option<Vec<u8>> {
std::thread::sleep(std::time::Duration::from_millis(150));
None
}
/// A failure whose wait is over is retried like a new texture: not while every place is taken
/// (it keeps answering `Failed`, and starts nothing), and as soon as one is free.
#[test]
fn a_failure_that_is_due_waits_for_a_free_place() {
let bounds = Bounds {
states: 4096,
resources: 4096,
downloads: 1,
};
let mut d = downloader("due", bounds, slowish_failure);
let wait_time = d.retry_after;
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
// Another texture takes the only place; the first one's retry becomes due but has to wait.
assert_eq!(d.state(Kind::Skin, &url(1)), Download::Pending);
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Failed);
assert_eq!(d.in_flight, 1);
d.retry_after = wait_time;
assert_eq!(wait(&mut d, &url(1)), Download::Failed);
// The place is free: the retry starts.
d.retry_after = std::time::Duration::ZERO;
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
assert_eq!(d.in_flight, 1);
}
/// A texture that is running is never the one dropped, however many settle behind it.
#[test]
fn a_running_download_keeps_its_place_in_the_states() {
let bounds = Bounds {
states: 2,
resources: 4096,
downloads: 16,
};
let mut d = downloader("running", bounds, sleepy_failure);
assert_eq!(d.state(Kind::Skin, &url(0)), Download::Pending);
for i in 1..6 {
d.set_state((Kind::Cape, format!("settled{i}")), Download::Ready);
}
assert_eq!(
d.states
.get(&(Kind::Skin, super::texture_hash(&url(0)).unwrap())),
Some(&Download::Pending)
);
assert!(d.states.len() <= 3);
assert_eq!(wait(&mut d, &url(0)), Download::Failed);
}
/// `resources` keeps to its bound too: a server's `texture` ids, one for each unique one, no
/// longer pile up, and one that was dropped is read from the packs again.
#[test]
fn the_resource_textures_keep_to_their_bound() {
let bounds = Bounds {
states: 4096,
resources: 8,
downloads: 16,
};
let mut d = downloader("resources", bounds, |_| None);
for i in 0..40 {
d.resource(&format!("patch/n/{i}"), "n", &format!("t{i}"));
assert!(d.resources.len() <= 8 && d.resource_order.len() == d.resources.len());
}
let kept: Vec<usize> = (0..40)
.filter(|i| d.resources.contains_key(&format!("patch/n/{i}")))
.collect();
assert_eq!(kept, (32..40).collect::<Vec<_>>());
// Forgetting one by hand (as `again` does) leaves no stale key behind.
d.forget_resource("patch/n/35");
assert_eq!(d.resource_order.len(), d.resources.len());
assert!(!d.resource_order.iter().any(|l| l == "patch/n/35"));
// New packs start the count over.
d.set_resource_root(std::env::temp_dir());
assert!(d.resources.is_empty() && d.resource_order.is_empty());
}
}
+19 -98
View File
@@ -127,9 +127,6 @@ pub struct Sprites {
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<StillKey, u32>,
/// Names a connected-texture rule matches by name (`matchTiles`, `connectTiles`): each keeps a
/// layer of its own ([`Sprites::keep_apart`]).
apart: std::collections::HashSet<String>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
/// Textures [`Sprites::prefetch`] decoded ahead of their insert, by sprite name or
@@ -221,13 +218,6 @@ impl Sprites {
..Self::default()
}
}
/// Keeps each of `names` on a layer of its own, never shared with an identical texture: the
/// connected-texture rules that name them find their faces by layer, so a texture sharing it
/// would be matched too (a tile a rule chains onto, and a model's transparent placeholder
/// with the same pixels). Before the textures that bear these names are inserted.
pub fn keep_apart(&mut self, names: impl IntoIterator<Item = String>) {
self.apart.extend(names);
}
/// Write what this run decoded to the cache file.
pub fn save_disk_cache(&mut self) {
if let Some(disk) = &mut self.disk {
@@ -297,13 +287,8 @@ impl Sprites {
let mut resized = Vec::with_capacity(self.native.len());
self.pixels.clear();
for (from, frames, initial) in &self.native {
// Native frames already match the atlas size: share the immutable
// buffers with animations rather than copying every frame again.
let frames: Arc<Vec<Vec<u8>>> = if *from == size {
Arc::clone(frames)
} else {
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect())
};
let frames: Arc<Vec<Vec<u8>>> =
Arc::new(frames.iter().map(|f| resample(f, *from, size)).collect());
self.pixels.extend_from_slice(&frames[*initial]);
resized.push(frames);
}
@@ -701,24 +686,23 @@ impl Sprites {
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still =
(animation.is_none() && frames.len() == 1 && !self.apart.contains(name)).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut frame = std::collections::hash_map::DefaultHasher::new();
frames[0].hash(&mut frame);
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
StillKey {
frame: frame.finish(),
pbr: maps.finish(),
side,
settings: [
settings.strategy as u32,
settings.alpha_cutoff_bias.to_bits(),
],
flags: [settings.has_transparent, settings.item, opaque, translucent],
}
});
if let Some(&same) = still.as_ref().and_then(|k| self.still.get(k)) {
self.layers.insert(name.to_owned(), same);
return Ok(same);
@@ -888,35 +872,6 @@ mod tests {
use super::*;
use std::fs::{self, File};
#[test]
fn native_frames_at_atlas_size_are_shared_without_resampling() {
let pixels = vec![
[10, 20, 30, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
[40, 50, 60, 255].repeat((SPRITE_SIZE * SPRITE_SIZE) as usize),
];
let native = Arc::new(pixels.clone());
let smaller = Arc::new(vec![vec![12; 8 * 8 * 4], vec![34; 8 * 8 * 4]]);
let metadata = r#"{"frametime":2}"#;
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::clone(&native), 1),
(8, Arc::clone(&smaller), 0),
],
pbr: vec![[None, None], [None, None]],
animations: vec![
test_frames(0, pixels.clone(), metadata),
test_frames(1, smaller.as_ref().clone(), metadata),
],
..Default::default()
};
sprites.finish();
assert!(Arc::ptr_eq(&sprites.animations[0].frames, &native));
assert!(!Arc::ptr_eq(&sprites.animations[1].frames, &smaller));
assert_eq!(sprites.animations[0].pixels(1), pixels[1].as_slice());
let resized = resample(&smaller[0], 8, SPRITE_SIZE);
assert_eq!(sprites.animations[1].pixels(0), resized.as_slice());
assert_eq!(&sprites.pixels[..pixels[1].len()], pixels[1].as_slice());
}
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
@@ -1327,40 +1282,6 @@ mod tests {
assert_eq!(sprites.pixels.len(), 3 * 16 * 16 * 4);
}
#[test]
fn a_texture_a_connected_rule_matches_keeps_a_layer_of_its_own() {
// A resource pack's transparent placeholders: a model's layer and a tile another
// connected-texture rule chains onto (`matchTiles=./19.png`) have the same pixels. The
// rule finds its faces by layer, so the one it names can't share the others'.
let root =
std::env::temp_dir().join(format!("horizon-sprite-apart-{}", std::process::id()));
let directory = root.join("assets/a/textures/block");
fs::create_dir_all(&directory).unwrap();
let write = |name: &str| {
let mut encoder =
png::Encoder::new(File::create(directory.join(name)).unwrap(), 16, 16);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[0; 16 * 16 * 4])
.unwrap();
};
for name in ["placeholder.png", "named.png", "again.png"] {
write(name);
}
let mut sprites = Sprites::default();
sprites.keep_apart(["a:block/named".to_owned()]);
let placeholder = sprites.insert(&root, "a:block/placeholder").unwrap();
let named = sprites.insert(&root, "a:block/named").unwrap();
let again = sprites.insert(&root, "a:block/again").unwrap();
fs::remove_dir_all(&root).unwrap();
// The named one alone; the others still share.
assert_ne!(named, placeholder);
assert_eq!(again, placeholder);
assert_eq!(sprites.count(), 2);
}
#[test]
fn a_32px_only_atlas_does_not_add_an_extra_mipmap() {
let mut sprites = Sprites {
native: vec![
+22 -26
View File
@@ -605,9 +605,6 @@ pub struct Streaming {
/// Set while `update` runs, after it refreshed `cover`: nothing else can
/// change `extra` meanwhile, so `cover` answers without comparing them.
cover_current: bool,
/// `forced` and `mod_forced` as each column's distance to the nearest one, made again when
/// they change; read like `cover`, while `cover_current`.
forced_cover: Option<ForcedCover>,
/// Mesh the sections for drawing (false on a server thread).
pub mesh: bool,
/// The pregeneration band (`set_band`).
@@ -809,7 +806,6 @@ impl GenerationScan {
forced: input.ranges.forced,
mod_forced: input.ranges.mod_forced,
cover: input.ranges.cover,
forced_cover: None,
band: input.ranges.band,
};
self.remember(ranges, input.world);
@@ -1070,10 +1066,8 @@ pub struct WorkerContext {
pub light: Arc<LightTables>,
}
mod bounded;
mod forced_cover;
mod relight_now;
use bounded::nearest_first;
use forced_cover::ForcedCover;
impl Streaming {
pub fn new<G: ChunkGenerator>(
generator: Arc<G>,
@@ -1182,7 +1176,6 @@ impl Streaming {
extra: Vec::new(),
cover: None,
cover_current: false,
forced_cover: None,
mesh: true,
band: None,
band_ranked: None,
@@ -1698,7 +1691,6 @@ impl Streaming {
let mut columns = std::mem::take(&mut self.mesh_columns_scratch);
columns.clear();
ExtraCover::refresh(&mut self.cover, &self.extra);
ForcedCover::refresh(&mut self.forced_cover, &self.forced, &self.mod_forced);
self.cover_current = true;
// The sweeps below walk every loaded column and proto chunk. Run them
// at once when the view moves and every eighth tick otherwise: a
@@ -1878,7 +1870,6 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
};
let (protos, generating) = (&self.protos, &self.generating);
@@ -2049,7 +2040,6 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
};
self.waiting.retain(|&(x, sy, z)| {
@@ -2155,7 +2145,6 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
},
world,
@@ -2294,7 +2283,6 @@ impl Streaming {
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::current(&self.cover, &self.extra, self.cover_current),
forced_cover: self.forced_cover.as_ref().filter(|_| self.cover_current),
band: self.band.as_ref(),
}
}
@@ -2575,8 +2563,6 @@ struct Ranges<'a> {
/// `extra` indexed by a grid or buckets: coverage queries without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
/// `forced` and `mod_forced` by distance (`Streaming::forced_cover`), while they match it.
forced_cover: Option<&'a ForcedCover>,
band: Option<&'a Band>,
}
/// The chunks a pregeneration task (`/pregen`) wants lit now: rows `z0..=z1`
@@ -2625,7 +2611,6 @@ impl Band {
+ i64::from(x - self.x0 + 8).max(0)
}
}
/// For every column near the extra areas, how far it lies outside the
/// nearest one's square (Chebyshev distance less its radius; 0 inside). A server has an area per player, and streaming asks of every
/// loaded and wanted column whether one reaches it, several times a tick:
@@ -2933,6 +2918,16 @@ impl Ranges<'_> {
.iter()
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
}
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// The columns meshed for drawing: those vanilla draws around the
/// player (`in_view_distance`), and forced ones.
fn in_view(&self, x: i32, z: i32) -> bool {
@@ -2944,7 +2939,18 @@ impl Ranges<'_> {
if self.radius >= 0 && dx * dx + dz * dz <= r * r {
return true;
}
self.near_forced(x, z, margin)
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
impl Drop for Streaming {
@@ -3478,7 +3484,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let generate = |x, z, priority, order| Queued {
@@ -3719,7 +3724,6 @@ mod tests {
forced: &self.forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
},
world: &self.world,
@@ -4103,7 +4107,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let square = |x: i32, z: i32, n: i32| {
@@ -4161,7 +4164,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let span = radius * 3 + 20;
@@ -4199,7 +4201,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
for x in -5i32..=15 {
@@ -4234,7 +4235,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + ((x - 40).abs().max((z + 9).abs()) - view).max(0);
@@ -4290,7 +4290,6 @@ mod tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
forced_cover: None,
band: None,
};
let level = |x: i32, z: i32| 31 + (x - 40).abs().max((z + 40).abs());
@@ -4431,7 +4430,6 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
let (scan, grid) = (ranges(None), ranges(Some(&cover)));
@@ -4475,7 +4473,6 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
let (scan, buckets) = (ranges(None), ranges(Some(&cover)));
@@ -4542,7 +4539,6 @@ mod cover_tests {
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
forced_cover: None,
band: None,
};
// The columns streaming asks about: everything kept, and the
@@ -1,165 +0,0 @@
//! The chunks a `/forceload` or a native mod's ticket holds, as `Ranges` reads them, and their
//! distance map.
use super::{FORCED_FEATURES_RING, FORCED_FULL_RING, FORCED_TERRAIN_RING, Ranges};
use crate::fast_hash::FxHashMap;
use std::collections::HashSet;
/// `Streaming::forced` and `mod_forced` as the Chebyshev distance from each column within
/// `REACH` of one to the nearest: `Ranges::in_main_range` answers by one lookup instead of going
/// through every forced chunk (it is asked for every column, several times a tick).
pub(super) struct ForcedCover {
forced: HashSet<(i32, i32)>,
mod_forced: HashSet<(i32, i32)>,
near: FxHashMap<(i32, i32), u8>,
}
impl ForcedCover {
/// The widest ring `in_main_range` asks of a forced chunk (margins 1 to 4).
const REACH: i32 = FORCED_TERRAIN_RING + 1;
/// Past this many forced chunks the cover is not made: the scan answers.
const MOST: usize = 4096;
pub(super) fn refresh(
cover: &mut Option<Self>,
forced: &HashSet<(i32, i32)>,
mod_forced: &HashSet<(i32, i32)>,
) {
if cover
.as_ref()
.is_some_and(|c| c.forced == *forced && c.mod_forced == *mod_forced)
{
return;
}
if forced.len() + mod_forced.len() > Self::MOST {
*cover = None;
return;
}
let mut near = FxHashMap::default();
for &(fx, fz) in forced.iter().chain(mod_forced) {
for dx in -Self::REACH..=Self::REACH {
for dz in -Self::REACH..=Self::REACH {
let d = dx.abs().max(dz.abs()) as u8;
near.entry((fx + dx, fz + dz))
.and_modify(|e: &mut u8| *e = (*e).min(d))
.or_insert(d);
}
}
}
*cover = Some(Self {
forced: forced.clone(),
mod_forced: mod_forced.clone(),
near,
});
}
/// Whether a forced chunk lies within `reach` of `(x, z)`, or `None` past `REACH`.
pub(super) fn within(&self, x: i32, z: i32, reach: i32) -> Option<bool> {
(reach <= Self::REACH).then(|| {
self.near
.get(&(x, z))
.is_some_and(|&d| i32::from(d) <= reach)
})
}
}
impl Ranges<'_> {
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
pub(super) fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
pub(super) fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// `in_main_range` past the player's circle: whether a forced chunk lies within the ring
/// `margin` asks for.
pub(super) fn near_forced(&self, x: i32, z: i32, margin: i32) -> bool {
// A forced chunk holds a FORCED ticket at level 31
// (`ChunkMap.FORCED_TICKET_LEVEL`), and the level rises one per ring
// (`ChunkTracker.computeLevelFromNeighbor`): the margin is the
// vanilla status ring the caller asks for.
let reach = match margin {
1 => FORCED_FULL_RING,
2 => FORCED_FEATURES_RING,
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
if let Some(hit) = self.forced_cover.and_then(|c| c.within(x, z, reach)) {
return hit;
}
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chunks(seed: &mut u64, n: usize, spread: i32) -> HashSet<(i32, i32)> {
let mut next = |m: i32| {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
(*seed % (2 * m as u64 + 1)) as i32 - m
};
(0..n).map(|_| (next(spread), next(spread))).collect()
}
/// With the forced chunks by distance, every column gets the answer the scan of every forced
/// chunk gives, at every margin streaming asks (and those past the map's reach), after the
/// forced chunks change too.
#[test]
fn the_distances_answer_as_the_scan() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut cover = None;
for case in 0..24 {
let spread = [3, 12, 40][case % 3];
let forced = chunks(&mut seed, 1 + case % 9, spread);
// Some mod tickets on forced chunks, some apart.
let mut mod_forced = chunks(&mut seed, case % 4, spread);
mod_forced.extend(forced.iter().take(case % 2));
ForcedCover::refresh(&mut cover, &forced, &mod_forced);
let made = cover.as_ref().expect("a few forced chunks get a map");
let ranges = |forced_cover| Ranges {
// The player's circle far away: only the forced chunks answer.
center: (10_000, 10_000),
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &mod_forced,
cover: None,
forced_cover,
band: None,
};
let (scan, map) = (ranges(None), ranges(Some(made)));
let span = spread + 10;
for z in -span..=span {
for x in -span..=span {
for margin in 0..=7 {
assert_eq!(
map.in_main_range(x, z, margin),
scan.in_main_range(x, z, margin),
"case {case} ({x}, {z}) margin {margin}"
);
}
for d in [false, true] {
assert_eq!(map.requested(x, z, d), scan.requested(x, z, d));
assert_eq!(map.chunk_kept(x, z, d), scan.chunk_kept(x, z, d));
assert_eq!(map.unloaded(x, z, d), scan.unloaded(x, z, d));
}
}
}
}
}
/// Too many forced chunks for a map: none is made, and the scan answers.
#[test]
fn past_the_most_no_map_is_made() {
let forced: HashSet<(i32, i32)> = (0..=ForcedCover::MOST as i32).map(|x| (x, 0)).collect();
let mut cover = None;
ForcedCover::refresh(&mut cover, &HashSet::new(), &HashSet::from([(0, 0)]));
assert!(cover.is_some());
ForcedCover::refresh(&mut cover, &forced, &HashSet::new());
assert!(cover.is_none());
}
}
+1 -11
View File
@@ -524,18 +524,8 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let packed_light = ((light & 63u) << 2u) | (((light >> 6u) & 63u) << 10u);
let g = (lo.y >> (8u * corner)) & 255u;
let t = lo.x;
// Corners 1 to 3's own alpha (`packed::ALPHA_STEP` times the code; 0: word 0's).
var code = 0u;
if corner == 1u {
code = (hi.x >> 28u) & 7u;
} else if corner == 2u {
code = (hi.y >> 24u) & 7u;
} else if corner == 3u {
code = (hi.y >> 27u) & 7u;
}
let alpha = select((code * 51u) << 24u, t & 0xff000000u, code == 0u);
let color = (g * ((t >> 16u) & 255u) / 255u) | ((g * ((t >> 8u) & 255u) / 255u) << 8u)
| ((g * (t & 255u) / 255u) << 16u) | alpha;
| ((g * (t & 255u) / 255u) << 16u) | (t & 0xff000000u);
return ArenaCorner(bitcast<vec3<u32>>(vec3<f32>(pos)),
vec3((u << 10u) | (v << 26u), (hi.x & 0xffffu) | (packed_light << 16u), color));
}
@@ -530,7 +530,7 @@
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Auto uses the peak Windows reports. Calibrate if highlights look clipped.": "Auto uses the peak Windows reports. Calibrate if highlights look clipped.",
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
+5 -5
View File
@@ -1989,13 +1989,13 @@ mod tests {
}
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
/// with HORIZON_TEST_RESOURCES set to the prepared resources.
/// with HORIZON_RESOURCES set to the prepared resources.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
#[ignore = "needs the prepared resources"]
fn index_on_vanilla_tree() {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let Some(root) = std::env::var_os("HORIZON_RESOURCES").map(std::path::PathBuf::from) else {
return;
};
let data = root.join("data");
let t = std::time::Instant::now();
let tree = Tree::load(&data);
+8 -39
View File
@@ -26,9 +26,8 @@ pub struct Tab {
/// The page of tabs it is on: vanilla's are 0, mods' after, as NeoForge
/// pages them.
pub page: u32,
/// A Polytone creative tab modifier's `name`: its text (a translation key when
/// the flag is set) and colour, over the tab's own.
pub name: Option<(String, Option<u32>, bool)>,
/// A Polytone creative tab modifier's `name`: its text and colour, over the tab's own.
pub name: Option<(String, Option<u32>)>,
}
impl Tab {
/// The tab without its stacks.
@@ -104,28 +103,25 @@ pub struct Creative {
/// The packs' Polytone creative tab modifiers, applied by `rebuild`.
pack: Vec<crate::polytone_creative_tabs::TabModifier>,
}
/// A text component's text, its colour (a name, or `#rrggbb`) and whether the
/// text is a `translate` key, to be resolved when drawn.
fn component_text(json: &Value) -> (String, Option<u32>, bool) {
/// A text component's plain text and its colour (a name, or `#rrggbb`).
fn component_text(json: &Value) -> (String, Option<u32>) {
match json {
Value::String(s) => (s.clone(), None, false),
Value::String(s) => (s.clone(), None),
Value::Object(o) => {
let translate = o.get("translate").and_then(Value::as_str);
let text = o
.get("text")
.or_else(|| o.get("translate"))
.and_then(Value::as_str)
.or(translate)
.unwrap_or("")
.to_owned();
let translate = o.get("text").is_none() && translate.is_some();
let color = o.get("color").and_then(Value::as_str).and_then(|c| {
c.strip_prefix('#')
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.or_else(|| crate::text::named_color(c))
});
(text, color, translate)
(text, color)
}
_ => (String::new(), None, false),
_ => (String::new(), None),
}
}
/// The grid's stacks for one tab, search text and language.
@@ -916,16 +912,6 @@ impl Creative {
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
(x, y)
}
/// `CreativeModeInventoryScreen.checkTabHovering`: the tab whose icon
/// rectangle, 3 pixels in from its 26x32 slot (`isHovering(x + 3, y + 3,
/// 21, 27)`), holds the cursor. The screen draws its display name.
pub fn hovered_tab(tabs: &[Tab], origin: (f32, f32), cursor: (f32, f32)) -> Option<usize> {
tabs.iter().position(|tab| {
let (x, y) = Self::tab_position(tab);
let (x, y) = (origin.0 + x + 3.0, origin.1 + y + 3.0);
cursor.0 >= x && cursor.0 < x + 21.0 && cursor.1 >= y && cursor.1 < y + 27.0
})
}
/// `CreativeModeInventoryScreen.extractTabButton`'s cursor: an unselected
/// tab asks for the pointing hand when the whole-pixel mouse, relative to
/// the screen origin, is strictly inside its 26x32 button.
@@ -1803,21 +1789,4 @@ mod tests {
assert!(c.tab_headers().is_empty());
assert_eq!(c.search_tab(), None);
}
/// `CreativeModeInventoryScreen.checkTabHovering`: the 3-pixel inset of a
/// tab's 26x32 slot (21x27) is the hover area whose name the screen shows.
#[test]
fn only_a_tabs_inset_icon_is_hovered() {
let screen = tooltip_tabs();
let tab = &screen.tabs[0];
let (x, y) = Creative::tab_position(tab);
let at =
|dx: f32, dy: f32| Creative::hovered_tab(&screen.tabs, (0.0, 0.0), (x + dx, y + dy));
assert_eq!(at(4.0, 4.0), Some(0));
assert_eq!(at(23.5, 4.0), Some(0));
assert_eq!(at(2.0, 4.0), None);
assert_eq!(at(24.0, 4.0), None);
assert_eq!(at(4.0, 2.0), None);
assert_eq!(at(4.0, 30.0), None);
}
}
+11 -105
View File
@@ -8,51 +8,28 @@ use crate::{
text::{Style, Text},
};
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the last style `wanted`
/// under the cursor, in the same visual order and advances as rendering.
/// A drawn glyph is hit where it is drawn (`BakedSheetGlyph.GlyphInstance`),
/// even far below the line when its font's `ascent` moves it; an empty one
/// covers the line's 9 pixels (`EmptyArea`).
/// `ActiveTextCollector.ClickableStyleFinder.accept`: the style under a drawn
/// message glyph, using the same visual order and advances as rendering.
pub fn line_style_at(
font: &Font,
text: &Text,
x: f32,
y: f32,
cursor: (f32, f32),
wanted: impl Fn(&Style) -> bool,
) -> Option<Style> {
let mut left = cursor.0 - x;
if left < 0.0 || !(0.0..9.0).contains(&(cursor.1 - y)) {
return None;
}
let text = text.resolve_legacy();
let visual = crate::bidi::visual(&text);
let mut pen = x;
let mut found = None;
for (c, style) in visual.chars() {
let advance = font.advance(c, style);
let object = c == '\u{fffc}' && style.inline_object.is_some();
let glyph = (!object).then(|| font.glyph_styled(c, style));
let (left, top, bottom) = match glyph {
Some(g) if g.rect.is_some() => {
let thick = if style.is_bold() { 0.1 } else { 0.0 };
let shear = if style.is_italic() {
1.0 - 0.25 * g.bottom
} else {
0.0
};
// Dialog text is drawn with a shadow, which extends the bottom.
(
pen + g.left + shear - thick,
y + g.top - thick,
y + g.bottom + g.offset + thick,
)
}
_ => (pen, y, y + 9.0),
};
let inside = (left..pen + advance).contains(&cursor.0) && (top..bottom).contains(&cursor.1);
if inside && wanted(style) {
found = Some(style.clone());
left -= font.advance(c, style);
if left < 0.0 {
return Some(style.clone());
}
pen += advance;
}
found
None
}
/// `HeaderAndFooterLayout.DEFAULT_HEADER_AND_FOOTER_HEIGHT`.
@@ -508,7 +485,7 @@ mod tests {
]),
&mut |_| Text::empty(),
);
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y), |_| true);
let style = |x, y| line_style_at(&font, &text, 10.0, 20.0, (x, y));
assert!(style(9.9, 20.0).is_none());
assert_eq!(style(17.9, 20.0).unwrap().click, None);
for x in [18.0, 21.0, 25.9] {
@@ -526,77 +503,6 @@ mod tests {
assert!(style(21.0, 29.0).is_none());
}
#[test]
fn a_glyph_moved_down_by_its_ascent_is_clicked_where_it_is_drawn() {
use crate::{component, hud::Rect, text::ClickEvent};
let root =
std::env::temp_dir().join(format!("horizon-dialog-ascent-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let mut sheet = Vec::new();
{
let mut encoder = png::Encoder::new(&mut sheet, 8, 8);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&[255; 8 * 8 * 4])
.unwrap();
}
for (path, bytes) in [
(
"assets/minecraft/font/low.json",
br#"{"providers":[{"type":"bitmap","file":"minecraft:font/box.png","height":8,"ascent":-28,"chars":["a"]}]}"#
.as_slice(),
),
("assets/minecraft/textures/font/box.png", sheet.as_slice()),
] {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
let font = Font::load(
&root,
&std::collections::HashMap::new(),
&[],
(4096, 1024),
(512, 512),
Rect {
u0: 0.0,
v0: 0.0,
u1: 0.0,
v1: 0.0,
w: 1,
h: 1,
},
);
let _ = std::fs::remove_dir_all(&root);
let text = component::to_text(
&serde_json::json!([
{"text": "a", "font": "minecraft:low"},
{"text": "a", "font": "minecraft:low", "click_event": {
"action": "custom", "id": "test:requests"
}},
]),
&mut |_| Text::empty(),
);
let click = |x, y| {
line_style_at(&font, &text, 10.0, 20.0, (x, y), |s| s.click.is_some())
.and_then(|s| s.click)
};
let requests = Some(ClickEvent::Custom {
id: "test:requests".into(),
payload: None,
});
for (x, y) in [(19.0, 55.0), (22.0, 60.0), (27.9, 63.9)] {
assert_eq!(click(x, y), requests, "drawn 35 below the line at {x}, {y}");
}
assert_eq!(click(22.0, 24.0), None, "the line's own row holds nothing");
assert_eq!(click(22.0, 54.9), None, "above the glyph");
assert_eq!(click(22.0, 65.0), None, "below the glyph and its shadow");
assert_eq!(click(15.0, 60.0), None, "the plain glyph left of it");
}
#[test]
fn grids_pack_full_rows_and_centre_the_rest() {
// AuthMe's two buttons: one row, 2 apart.
+1 -2
View File
@@ -166,8 +166,7 @@ impl FurnaceCooking for Furnace {
self.cooking_timer = 0;
}
} else if self.cooking_timer > 0 {
// Vanilla Mth.clamp is min(max(v, 0), max): saturates at a negative total instead of panicking.
self.cooking_timer = (self.cooking_timer - 2).max(0).min(self.cooking_total);
self.cooking_timer = (self.cooking_timer - 2).clamp(0, self.cooking_total);
}
(was_lit != lit).then_some(lit)
}
+7 -7
View File
@@ -1524,14 +1524,14 @@ impl LevelAccess for GameLevel<'_> {
fn block_entity_signal(
&self,
pos: BlockPos,
state: BlockStateId,
_state: BlockStateId,
_direction: Direction,
) -> i32 {
// TestBlock.ownSignal: the block state's mode gates the entity.
// TestBlock.ownSignal.
if let Some(BlockEntity::TestBlock(b)) =
self.world.block_entities.get(&(pos.x, pos.y, pos.z))
{
return crate::game::test_blocks::own_signal(self.registry, state, b.powered);
return b.own_signal();
}
// TrappedChestBlock.getSignal: the players with it open, at most 15.
if let Some(count) = self.lid_openers.get(&(pos.x, pos.y, pos.z)) {
@@ -2873,9 +2873,9 @@ struct Game<'a> {
water_plane_memo: std::cell::Cell<Option<f32>>,
/// The camera's block and the second `water_plane` last scanned at.
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
/// What the boats' and minecarts' ticks know about the mobs: their box reach
/// (`vehicle_mob_reach`) and a grid of their boxes (`mobs_meeting`).
vehicle_mobs: boat_passengers::VehicleMobs,
/// The mobs' box reach while the boats and minecarts tick, with the mob count it was
/// worked out for (`vehicle_mob_reach`).
vehicle_mob_reach: std::cell::Cell<Option<(usize, horizon_client::mobs::BoxReach)>>,
persist: persist::EntityMemory,
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
@@ -11992,7 +11992,7 @@ impl<'a> Game<'a> {
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
water_plane_scan: Default::default(),
vehicle_mobs: Default::default(),
vehicle_mob_reach: Default::default(),
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
@@ -1255,7 +1255,7 @@ impl Game<'_> {
}
self.check_inventory_trigger();
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
let me = self.player_entity_if_listened("minecraft:tick");
let me = self.player_entity_for("minecraft:tick");
self.trigger("minecraft:tick", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -1289,7 +1289,7 @@ impl Game<'_> {
}
self.tick_marks();
if self.ticks.is_multiple_of(20) {
let me = self.player_entity_if_listened("minecraft:location");
let me = self.player_entity_for("minecraft:location");
self.trigger("minecraft:location", |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -1,158 +0,0 @@
//! An allay dances to the jukebox it heard first: `Allay.setJukeboxPlaying`
//! keeps that jukebox's position while it dances, and only a stop from there
//! ends the dance; another jukebox's `jukebox_play` (every 20 ticks of its
//! song) starts it again (tools/test_allay_jukebox_source_parity.py measures
//! the same on vanilla 26.3).
use crate::game::*;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
for command in [
"tick freeze",
"forceload add 0 0",
"fill 0 99 0 15 99 15 stone",
"fill 7 99 7 9 102 9 glass",
"fill 8 100 8 8 101 8 air",
"setblock 5 100 8 jukebox",
"setblock 12 100 8 jukebox",
"summon allay 8.5 100 8.5 {PersistenceRequired:1b,UUID:[I;2577,0,0,1]}",
] {
assert!(game.run_command(command), "{command}");
}
step(&mut game, 12);
game
}
fn step(game: &mut Game<'_>, ticks: usize) {
for _ in 0..ticks {
assert!(game.run_command("tick step 1"));
game.tick_server(false).unwrap();
}
}
fn dancing(game: &Game<'_>) -> bool {
game.mobs
.iter()
.find(|m| m.species() == horizon_client::mobs::Species::Allay)
.expect("the allay")
.extra
.flag
}
fn play(game: &mut Game<'_>, x: i32, item: &str) {
assert!(game.run_command(&format!(
"item replace block {x} 100 8 container.0 with {item}"
)));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn stopping_the_first_jukebox_stops_the_dance_until_the_other_plays_again() {
let mut game = game();
assert!(!dancing(&game));
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 5);
assert!(dancing(&game), "jukebox A starts the dance");
play(&mut game, 12, "minecraft:music_disc_cat");
step(&mut game, 25);
assert!(dancing(&game));
// B has played for 25 ticks: its next jukebox_play is 15 ticks away.
play(&mut game, 5, "minecraft:air");
step(&mut game, 1);
assert!(
!dancing(&game),
"the stop of A, the dance's jukebox, ends it though B still plays"
);
step(&mut game, 20);
assert!(dancing(&game), "B's next jukebox_play starts it again");
}
// Controls added after the baseline: the source through an in-place load, a
// NoAI allay, and the out-of-reach check of `aiStep`.
const ALLAY: &str = "00000a11-0000-0000-0000-000000000001";
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_data_merge_keeps_the_dance_and_its_jukebox() {
let mut game = game();
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 5);
play(&mut game, 12, "minecraft:music_disc_cat");
step(&mut game, 5);
assert!(game.run_command(&format!("data merge entity {ALLAY} {{CustomName:\"x\"}}")));
step(&mut game, 1);
assert!(dancing(&game), "Entity.load keeps DATA_DANCING");
// The kept jukebox is still A: B's stop does not end the dance, A's does.
play(&mut game, 12, "minecraft:air");
step(&mut game, 1);
assert!(dancing(&game));
play(&mut game, 5, "minecraft:air");
step(&mut game, 1);
assert!(!dancing(&game));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_a_no_ai_allay_never_dances() {
let mut game = game();
assert!(game.run_command(&format!("data merge entity {ALLAY} {{NoAI:1b}}")));
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 25);
assert!(!dancing(&game));
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn control_an_allay_out_of_reach_stops_on_a_twentieth_tick() {
let mut game = game();
play(&mut game, 5, "minecraft:music_disc_cat");
step(&mut game, 2);
assert!(dancing(&game));
assert!(game.run_command("fill 7 99 7 9 102 9 air"));
assert!(game.run_command(&format!("tp {ALLAY} 5.5 112 8.5")));
// Farther than 10 blocks from A's centre: stopped by the next tickCount % 20.
step(&mut game, 20);
assert!(!dancing(&game));
}
+24 -6
View File
@@ -3,6 +3,7 @@
//! and the hand-over interaction (`Allay.mobInteract`).
use super::Game;
use horizon_client::{
containers::BlockEntity,
first_person::Hand,
items::ItemStack,
mobs::{Species, allay},
@@ -73,6 +74,9 @@ impl Game<'_> {
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
// The playing jukeboxes, found at the first allay: no allay starts
// or stops one, and the block entities run into the thousands.
let mut jukeboxes: Option<Vec<(i32, i32, i32)>> = None;
for i in 0..self.mobs.len() {
if self.mobs[i].species() != Species::Allay
|| !self.mobs[i].alive()
@@ -81,8 +85,26 @@ impl Game<'_> {
continue;
}
let pos = self.mobs[i].position;
// The dance follows the jukebox events the allay hears
// (`mob_listen`), `Allay.tick` and `aiStep`.
// Dancing: a playing jukebox within 10 blocks.
let jukeboxes = jukeboxes.get_or_insert_with(|| {
self.world
.block_entities
.iter()
.filter(|(_, e)| matches!(e, BlockEntity::Jukebox(j) if j.playing))
.map(|(k, _)| *k)
.collect()
});
let nearest = jukeboxes
.iter()
.map(|k| {
(f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
})
.fold(f64::INFINITY, f64::min);
// `Allay.setDancing` wants effective AI: a `NoAI` allay never dances.
let panicking = horizon_client::mobs::goals::should_panic(&self.mobs[i]);
self.mobs[i].extra.flag = allay::dances(self.mobs[i].no_ai, panicking, nearest);
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
self.mobs[i].extra.point = None;
continue;
@@ -403,7 +425,3 @@ impl Game<'_> {
false
}
}
#[cfg(test)]
#[path = "allay_jukebox_source_tests.rs"]
mod allay_jukebox_source_tests;
+42 -313
View File
@@ -7,13 +7,10 @@
use super::{Game, boats::Boat, lan_mobs::guest_bounds};
use horizon_client::{
camera::wrap_degrees,
fast_hash::FxHashMap,
join_seq::ListStamp,
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders, seen_grid::SeenGrid},
mobs::{BoxReach, Mob, Species, Who, drops::is_animal, equine, riders},
scoreboard::pushable_by,
};
use horizon_core::geometry::{Aabb, Vec3};
use std::cell::{Cell, RefCell};
/// `Entity.boardingCooldown` once a mob leaves its boat.
pub(super) const BOARDING_COOLDOWN: i32 = 60;
@@ -21,35 +18,6 @@ pub(super) const BOARDING_COOLDOWN: i32 = 60;
/// feet are.
const PLAYER_RIDING_OFFSET: f64 = 0.6;
/// How many box queries the walk over every mob answers, once a write to the mob list has
/// made the grid stale, before a new grid is built: about what building one costs (with
/// 25,000 mobs, six to eight walks). It doubles with each grid of a stretch, so vehicles
/// that each write a mob (a boat pushing the pig beside it) never pay more for grids than
/// for their walks.
const WALKS_BEFORE_GRID: u32 = 8;
/// What a stretch of vehicle ticks knows about the mobs, so a boat or a minecart reads the
/// mobs near it and not every mob of the level ([`Game::mobs_meeting`]).
#[derive(Default)]
pub(super) struct VehicleMobs {
/// The mobs' [`BoxReach`] with the mob count it was worked out for; None outside a stretch.
reach: Cell<Option<(usize, BoxReach)>>,
grid: RefCell<MobGrid>,
/// The last query's candidates, kept for their allocation.
near: Cell<Vec<u32>>,
/// Each ridden vehicle's mobs by slot, for the mob list as it stood ([`Game::vehicle_riders`]).
riders: RefCell<Option<(ListStamp, FxHashMap<u32, Vec<usize>>)>>,
}
#[derive(Default)]
struct MobGrid {
/// The mobs' boxes by column and the list they were read from: they hold until that
/// list's next write ([`ListStamp`]), which every push, pick-up, death and join is.
built: Option<(ListStamp, SeenGrid)>,
/// The queries walked since the grid went stale, and how many are before the next grid.
walked: u32,
patience: u32,
}
/// `Entity.canRide` for a mob species: `Warden` and `WitherBoss` override it
/// to return false, so no vehicle ever takes them aboard.
pub(super) fn species_can_ride(species: Species) -> bool {
@@ -98,165 +66,32 @@ pub(super) fn boat_pushes(boat: Aabb, other: Aabb, other_is_boat: bool) -> bool
}
impl Game<'_> {
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
/// Starts or ends a stretch of vehicle ticks (`tick_boats`, `tick_carts`): while one runs,
/// a vehicle's box query ([`Self::mobs_meeting`]) reads the mobs near it from a grid of
/// their boxes, or tests the mobs' positions against their [`BoxReach`] first. A mob
/// joining or leaving works the reach out again, and so does [`Self::vehicle_picked_up`].
/// The first query that needs one works it out (the reach's count is one no list has), so
/// a level without boats or carts never walks its mobs for them.
/// a vehicle's box query tests the mobs' positions against their [`BoxReach`] first.
/// A mob joining or leaving works it out again, and so does [`Self::vehicle_picked_up`].
pub(super) fn vehicles_ticking(&self, on: bool) {
let reach = on.then(|| (usize::MAX, BoxReach::default()));
self.vehicle_mobs.reach.set(reach);
// The boats' grid serves the carts ticking right after them; an older one goes.
let mut grid = self.vehicle_mobs.grid.borrow_mut();
let stamp = self.mobs.stamp();
let built = grid.built.take().filter(|(s, _)| *s == stamp);
*grid = MobGrid {
built,
..Default::default()
};
self.vehicle_mob_reach
.set(on.then(|| (self.mobs.len(), BoxReach::of(&self.mobs))));
}
/// A vehicle picked a mob up: a shulker turns its lid up (`Shulker.startRiding`), which
/// changes its box, so the next query works the [`BoxReach`] out again.
pub(super) fn vehicle_picked_up(&self) {
if let Some((_, reach)) = self.vehicle_mobs.reach.get() {
self.vehicle_mobs.reach.set(Some((usize::MAX, reach)));
if let Some((_, reach)) = self.vehicle_mob_reach.get() {
self.vehicle_mob_reach.set(Some((usize::MAX, reach)));
}
}
/// The mobs' [`BoxReach`] while vehicles tick, none otherwise.
pub(super) fn vehicle_mob_reach(&self) -> Option<BoxReach> {
let (count, reach) = self.vehicle_mobs.reach.get()?;
let (count, reach) = self.vehicle_mob_reach.get()?;
if count == self.mobs.len() {
return Some(reach);
}
let reach = BoxReach::of(&self.mobs);
self.vehicle_mobs.reach.set(Some((self.mobs.len(), reach)));
self.vehicle_mob_reach.set(Some((self.mobs.len(), reach)));
Some(reach)
}
/// The slots, ascending, of the mobs whose vehicle is `id`, whatever their state. While
/// vehicles tick, one walk over the mobs answers for every vehicle until the mob list is
/// next written: each boat asks for its passengers and each cart on rails whether it is
/// ridden, and each one walked every mob of the level.
fn vehicle_riders(&self, id: u32) -> Vec<usize> {
let rides = |v: u32| move |&j: &usize| self.mobs[j].extra.mount == Some(v);
if !self.vehicle_mobs_indexed() {
return (0..self.mobs.len()).filter(rides(id)).collect();
}
let stamp = self.mobs.stamp();
let mut riders = self.vehicle_mobs.riders.borrow_mut();
if riders.as_ref().is_none_or(|(s, _)| *s != stamp) {
let mut by_vehicle = FxHashMap::<u32, Vec<usize>>::default();
for (j, m) in self.mobs.iter().enumerate() {
if let Some(v) = m.extra.mount {
by_vehicle.entry(v).or_default().push(j);
}
}
*riders = Some((stamp, by_vehicle));
}
let of = riders
.as_ref()
.and_then(|(_, by_vehicle)| by_vehicle.get(&id));
of.cloned().unwrap_or_default()
}
/// Whether a mob (one not removed) rides vehicle `id`: `Entity.isVehicle` for a cart.
pub(super) fn mob_rides(&self, id: u32) -> bool {
let riders = self.vehicle_riders(id);
riders.iter().any(|&j| !self.mobs[j].removed)
}
/// Whether what a stretch of vehicle ticks reads from the mob list may stand in for a walk
/// over it: a stretch runs, and no native mod is loaded (a hook can move a mob unseen, as
/// for the carts' `CartIndex`).
fn vehicle_mobs_indexed(&self) -> bool {
self.vehicle_mobs.reach.get().is_some() && horizon_client::mod_api::mods().is_empty()
}
/// The slots, ascending, of the mobs whose box may meet `area` into `out` (a superset: the
/// caller still tests each box), from a grid of the mobs' boxes that the first query of a
/// stretch of vehicle ticks builds. False: walk them all. That is outside a stretch or with
/// native mods loaded ([`Self::vehicle_mobs_indexed`]), when the grid cannot say, and for
/// a while after a write to the mob list ([`MobGrid`]).
fn vehicle_mobs_near(&self, area: Aabb, out: &mut Vec<u32>) -> bool {
if !self.vehicle_mobs_indexed() {
return false;
}
let stamp = self.mobs.stamp();
let mut grid = self.vehicle_mobs.grid.borrow_mut();
if grid.built.as_ref().is_none_or(|(s, _)| *s != stamp) {
if grid.walked < grid.patience {
grid.walked += 1;
return false;
}
grid.built = Some((stamp, SeenGrid::new(self.mobs.iter().map(Mob::bounds))));
grid.walked = 0;
grid.patience = (grid.patience * 2).max(WALKS_BEFORE_GRID);
}
let built = grid.built.as_ref();
built.is_some_and(|(_, cells)| cells.candidates(area, out))
}
/// A vehicle's box query: `each` gets every mob whose box meets `area`, in slot order,
/// with its slot and its box. While vehicles tick it reads the mobs near the area
/// ([`Self::vehicle_mobs_near`]) or, walking them all, tests each position against the
/// [`BoxReach`] before working the box out. The mobs and their order are those of a walk
/// that tests every box.
pub(super) fn mobs_meeting(&self, area: Aabb, mut each: impl FnMut(usize, &Mob, Aabb)) {
let mut near = self.vehicle_mobs.near.take();
if self.vehicle_mobs_near(area, &mut near) {
for &j in &near {
let m = &self.mobs[j as usize];
let b = m.bounds();
if b.intersects(area) {
each(j as usize, m, b);
}
}
} else {
let reach = self.vehicle_mob_reach();
for (j, m) in self.mobs.iter().enumerate() {
if reach.is_some_and(|r| !r.may_meet(m, area)) {
continue;
}
let b = m.bounds();
if b.intersects(area) {
each(j, m, b);
}
}
}
self.vehicle_mobs.near.set(near);
}
/// The slots, ascending, of the mobs whose box may meet a cart, read from the mobs' grid
/// once per cart. None when the mobs are the fewer (asking the carts' grid once per mob
/// is then the shorter way) or the grid does not answer.
pub(super) fn mobs_near_carts(&self) -> Option<Vec<u32>> {
if self.mobs.len() <= self.carts.len() {
return None;
}
let (mut slots, mut near) = (Vec::new(), Vec::new());
for c in self.carts.iter() {
if !self.vehicle_mobs_near(c.bounds(), &mut near) {
return None;
}
slots.extend_from_slice(&near);
}
slots.sort_unstable();
slots.dedup();
Some(slots)
}
/// Whether mob `m`, whose box `reach` bounds, may touch a cart: the carts' grid (`cells`)
/// names the carts near it, or every cart when the grid cannot (a superset either way).
pub(super) fn mob_may_meet_cart(
&self,
reach: BoxReach,
m: &Mob,
cells: &SeenGrid,
near: &mut Vec<u32>,
) -> bool {
if !cells.candidates(reach.around(m.position), near) {
near.clear();
near.extend(0..self.carts.len() as u32);
}
near.iter()
.any(|&k| reach.may_meet(m, self.carts[k as usize].bounds()))
}
/// The players in boat `id`: the window's player (or the server player
/// acting as it) and the server's other players.
pub(super) fn boat_players(&self, id: u32) -> usize {
let guests = self.lan.as_ref().map_or(0, |h| {
h.hub
@@ -270,9 +105,12 @@ impl Game<'_> {
/// The mobs in boat `id`, in their seat order after the players
/// (`Entity.addPassenger` puts a player first).
pub(super) fn boat_mobs(&self, id: u32) -> Vec<usize> {
let mut riders = self.vehicle_riders(id);
riders.retain(|&j| !self.mobs[j].removed && self.mobs[j].alive());
riders
(0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
m.extra.mount == Some(id) && !m.removed && m.alive()
})
.collect()
}
/// `getPassengers().size()` of boat `id`.
pub(super) fn boat_passengers(&self, id: u32) -> usize {
@@ -336,12 +174,18 @@ impl Game<'_> {
/// `AbstractBoat.canVehicleCollide`: the other boats, minecarts,
/// shulkers and pushable mobs and players, but not its own passengers.
pub(super) fn boat_obstacles(&self, id: u32, area: Aabb) -> Vec<Aabb> {
let mut out = Vec::new();
self.mobs_meeting(area, |j, m, b| {
if pushable(m) && (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id)) {
out.push(b);
}
});
let reach = self.vehicle_mob_reach();
let mut out: Vec<Aabb> = (0..self.mobs.len())
.filter(|&j| {
let m = &self.mobs[j];
// The box first: it turns away nearly every mob of the level.
reach.is_none_or(|r| r.may_meet(m, area))
&& m.bounds().intersects(area)
&& pushable(m)
&& (m.extra.mount.is_none() || self.mob_root_vehicle(j) != Some(id))
})
.map(|j| self.mobs[j].bounds())
.collect();
out.extend(
self.boats
.iter()
@@ -386,14 +230,19 @@ impl Game<'_> {
let pick_up = self.boat_players(id) == 0;
// canAddPassenger, which startRiding asks after the checks below.
let room = !self.boats[i].under_water();
// The mobs near the two boxes: the teams are only worked out when something
// touches the boat.
let mut near = Vec::new();
self.mobs_meeting(area.union_bounds(bb), |j, _, mb| {
if mb.intersects(area) || mb.intersects(bb) {
near.push(j);
}
});
// The box first: it turns away nearly every mob of the level, and the
// teams are only worked out when something touches the boat.
let reach = self.vehicle_mob_reach();
let sweep = area.union_bounds(bb);
let near: Vec<usize> = (0..self.mobs.len())
.filter(|&j| {
if reach.is_some_and(|r| !r.may_meet(&self.mobs[j], sweep)) {
return false;
}
let mb = self.mobs[j].bounds();
mb.intersects(area) || mb.intersects(bb)
})
.collect();
let outside = self.players_outside(id);
let touching = !near.is_empty()
|| outside
@@ -682,124 +531,4 @@ mod tests {
assert!(boat_pushes(boat, at(64.5), true));
assert!(!boat_pushes(boat, at(64.5625), true));
}
/// A mob pushes the cart its box touches while a mob far from every cart pushes none and
/// is not pushed, whichever grid finds them: the carts' (four carts in four of its columns,
/// asked once per mob while the mobs are the fewer) or, in a crowd, the mobs' (asked once
/// per cart).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn only_the_mob_touching_a_cart_pushes_it() {
use super::super::minecarts::{CartKind, Minecart};
let pushed = [0, 12].map(|crowd: u32| {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
for (i, (x, z)) in [(0.5, 0.5), (20.5, 0.5), (0.5, 20.5), (20.5, 20.5)]
.into_iter()
.enumerate()
{
let at = Vec3::new(x, 64.0, z);
game.carts
.push(Minecart::new(100 + i as u32, CartKind::Chest, at));
}
// 0.5 west of the second cart (boxes 0.9 and 0.98 wide), in the middle of them
// all, and the crowd well away.
let spots = [(20.0, 0.5), (10.5, 10.5)].into_iter();
let crowd = (0..crowd).map(|i| (40.5 + 3.0 * f64::from(i), 40.5));
for (i, (x, z)) in spots.chain(crowd).enumerate() {
let at = Vec3::new(x, 64.0, z);
game.mobs.push(Mob::new(1 + i as u32, pig.clone(), at, 0.0));
}
game.tick_carts();
let velocity = |id| game.mobs.iter().find(|m| m.id == id).unwrap().velocity;
assert!(velocity(1).x < 0.0, "pushed back west: {:?}", velocity(1));
let idle = game.mobs.iter().filter(|m| m.velocity == Vec3::ZERO);
assert_eq!(idle.count(), game.mobs.len() - 1);
velocity(1)
});
assert_eq!(pushed[0], pushed[1]);
}
/// A vehicle's box query during a stretch of vehicle ticks names the mobs a walk over
/// every box names, in the same order, also right after a mob moved into the box (a
/// push or a pick-up writes the mob list in the middle of a stretch).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vehicles_box_query_matches_a_walk_over_every_mob() {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
// A small LCG keeps the scene the same on every run.
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |range: f64| {
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
(seed >> 11) as f64 / (1u64 << 53) as f64 * range
};
for id in 1..=400 {
let at = Vec3::new(next(48.0), 64.0 + next(3.0), next(48.0));
game.mobs.push(Mob::new(id, pig.clone(), at, 0.0));
}
let mut met = 0;
for round in 0..300 {
// Three stretches, each with two writes to the mob list.
if round % 100 == 0 {
game.vehicles_ticking(true);
}
let corner = Vec3::new(next(46.0), 63.0 + next(3.0), next(46.0));
let size = Vec3::new(0.5 + next(2.5), 0.5 + next(1.5), 0.5 + next(2.5));
let area = Aabb::new(corner, corner.add_vector(size));
if round % 40 == 20 {
let j = round % game.mobs.len();
game.mobs[j].position = area.center();
}
let walked: Vec<Aabb> = game
.mobs
.iter()
.map(Mob::bounds)
.filter(|b| b.intersects(area))
.collect();
met += walked.len();
assert_eq!(game.boat_obstacles(u32::MAX, area), walked, "round {round}");
if round % 100 == 99 {
// The last comparisons ran against the grid: it stands, for the list as it is.
let fresh = |(stamp, _): &(ListStamp, SeenGrid)| *stamp == game.mobs.stamp();
assert!(
game.vehicle_mobs
.grid
.borrow()
.built
.as_ref()
.is_some_and(fresh)
);
game.vehicles_ticking(false);
}
}
assert!(met > 100, "the boxes met {met} mobs");
}
/// Who rides a vehicle follows the mob list through a stretch of vehicle ticks: a mob that
/// boards counts from then on, one removed no longer does, and a dead one still makes
/// its cart a vehicle but is no passenger of a boat.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_vehicle_knows_its_mob_riders_as_soon_as_they_board() {
let mut game = super::super::native_machines::tests::server_game();
let pig = game.res.mob_types().get("minecraft:pig").unwrap().clone();
for id in 1..=4 {
game.mobs.push(Mob::new(id, pig.clone(), Vec3::ZERO, 0.0));
}
let riders =
|game: &Game| [100, 101, 102].map(|id| (game.mob_rides(id), game.boat_mobs(id)));
game.mobs[0].extra.mount = Some(100);
game.vehicles_ticking(true);
let none = (false, vec![]);
assert_eq!(riders(&game), [(true, vec![0]), none.clone(), none.clone()]);
game.mobs[0].removed = true;
game.mobs[1].extra.mount = Some(101);
game.mobs[3].extra.mount = Some(102);
game.mobs[3].health = 0.0;
let after = [none, (true, vec![1]), (true, vec![])];
assert_eq!(riders(&game), after);
game.vehicles_ticking(false);
assert_eq!(riders(&game), after);
}
}
-11
View File
@@ -1467,13 +1467,6 @@ impl Game<'_> {
}
}
/// `BoatItem.use`: aim at a block or the water surface.
/// `Entity.setYRot` on a boat just made (its old rotation moves with it).
pub(super) fn set_boat_yaw(&mut self, id: u32, yaw: f32) {
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.yaw = yaw;
b.previous_yaw = yaw;
}
}
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(boat) = self.boat_to_place(stack) else {
return false;
@@ -1484,11 +1477,7 @@ impl Game<'_> {
boat.position.z.floor() as i32 >> 4,
);
self.world.modified.insert(key);
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
self.boats.push(boat);
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, stack, true);
// BoatItem.use: setYRot(player.getYRot()) after the stack's config.
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_vehicle_create(
self.boats.last().unwrap().entity_type().as_str(),
+9 -98
View File
@@ -1594,17 +1594,15 @@ impl Game<'_> {
&tr("gui.done", "Done"),
);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: a clickable word
// asks for the hand, and a show_text hover shows its tooltip,
// as the chat does.
if let Some(style) = pages.get(*page).and_then(|t| self.book_style_at(t, left)) {
if let Some(cursor) = link_cursor(&style) {
horizon_client::widgets::request_cursor(cursor);
}
if let Some(hover) = style.hover {
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
self.draw_tooltip_lines(batch, &lines, mx, my);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
// show_text hover event, as the chat shows it.
if let Some(hover) = pages
.get(*page)
.and_then(|t| self.book_style_at(t, left))
.and_then(|s| s.hover)
{
let lines = self.wrapped_rows(&hover, (w as i32 / 2).max(200));
self.draw_tooltip_lines(batch, &lines, mx, my);
}
}
}
@@ -1649,16 +1647,6 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
}
}
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: only a word with a click event asks
/// for the pointing hand over a book's page; a hover event alone shows its
/// tooltip.
fn link_cursor(style: &horizon_client::text::Style) -> Option<horizon_client::widgets::Cursor> {
style
.click
.is_some()
.then_some(horizon_client::widgets::Cursor::PointingHand)
}
#[cfg(test)]
mod title_box_tests {
use super::book_title_box;
@@ -1848,60 +1836,6 @@ mod lectern_signal_tests {
assert_eq!(signal(&mut game, Some(written), 1), 15, "last page");
}
}
impl BookScreen {
/// `BookViewScreen.isPauseScreen`: the class inherits `Screen`'s true, so
/// a book read in hand stops a single-player world. `LecternScreen`
/// overrides it false, so the same pages read on a lectern leave the
/// world ticking (its menu is an `AbstractContainerScreen`, which does
/// not pause either).
pub(super) fn pauses_world(&self) -> bool {
!matches!(
self,
Self::View {
lectern: Some(_),
..
}
)
}
}
#[cfg(test)]
mod pause_tests {
use super::BookScreen;
use horizon_client::{text::Text, ui};
fn view(lectern: Option<(i32, i32, i32)>) -> BookScreen {
BookScreen::View {
pages: Vec::<Text>::new(),
page: 0,
lectern,
}
}
#[test]
fn a_lecterns_book_does_not_pause_the_world() {
// BookViewScreen keeps Screen.isPauseScreen's true; LecternScreen
// overrides it false although both read the same pages.
assert!(view(None).pauses_world());
assert!(!view(Some((10, 64, -3))).pauses_world());
}
#[test]
fn a_book_being_edited_or_signed_pauses() {
// BookEditScreen and BookSignScreen do not override isPauseScreen.
let edit = |signing| BookScreen::Edit {
slot: 0,
pages: Vec::new(),
page: 0,
signing,
title: String::new(),
title_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
page_edit: ui::EditBox::new(0.0, 0.0, 0.0, 0.0),
};
assert!(edit(false).pauses_world());
assert!(edit(true).pauses_world());
}
}
#[cfg(test)]
mod lectern_reach_tests {
@@ -1995,26 +1929,3 @@ mod sign_tests {
}
}
}
#[cfg(test)]
mod link_cursor_tests {
use super::link_cursor;
use horizon_client::text::{ClickEvent, Style, Text};
use horizon_client::widgets::Cursor;
/// `HoveredTextEffects.TOOLTIP_AND_CURSOR`: a clickable word asks for the
/// pointing hand; a hover event alone only shows its tooltip.
#[test]
fn only_a_clickable_book_word_asks_for_the_hand() {
let link = Style {
click: Some(ClickEvent::OpenUrl("https://example.com".into())),
..Style::default()
};
let hover_only = Style {
hover: Some(Box::new(Text::literal("tip"))),
..Style::default()
};
assert_eq!(link_cursor(&link), Some(Cursor::PointingHand));
assert_eq!(link_cursor(&hover_only), None);
}
}
+18 -201
View File
@@ -1,10 +1,9 @@
//! Breeze particles (`Breeze.tick`): block dust at its feet while it stands,
//! a burst of twenty while it slides and a short trail along a long jump.
use super::{BlockStateId, Game};
use super::Game;
use horizon_client::{
blocks::RenderShape,
mobs::{Mob, Species, breeze},
particles::{ParticleOptions, ParticleType},
mobs::{Species, breeze},
particles::Particle,
};
impl Game<'_> {
@@ -34,16 +33,21 @@ impl Game<'_> {
} else {
self.mobs[i].extra.scratch[1] = 0;
}
let (block, state) = self.breeze_ground(&self.mobs[i]);
// `emitGroundParticles` skips an invisible block; the jump trail
// (`emitJumpTrailParticles`) does not ask.
if !jump_trail && self.res.facts().get(state).render_shape == RenderShape::Invisible {
continue;
let m = &self.mobs[i];
let (x, y, z) = (
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
);
let mut at = [x, y, z];
let mut state = self.world.block(x, y, z);
if self.res.facts().is_air(state) {
at[1] = (m.position.y - 0.2).floor() as i32;
state = self.world.block(x, at[1], z);
}
let Some((sprite, tint)) = self.particle_sprite(state, block) else {
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let m = &self.mobs[i];
let at = if jump_trail {
[
m.position.x + m.velocity.x,
@@ -53,198 +57,11 @@ impl Game<'_> {
} else {
[m.position.x, m.position.y, m.position.z]
};
// `ClientLevel.addParticle` with no speed: each crumb's own
// generator draws its lifetime, its little push and the quarter of
// the block's sprite it shows, never the level's random.
let options = ParticleOptions::block(ParticleType::Block, sprite, tint);
for _ in 0..amount {
self.particles.add(&options, at, [0.0; 3]);
let particle =
Particle::terrain(&mut self.world.random, at, [0.0; 3], sprite.clone(), tint);
self.particles.push(particle);
}
}
}
/// `Breeze.emitGroundParticles`'s block: `getInBlockState`, else
/// `getBlockStateOn`, which is `Entity.getOnPos(1.0E-5F)` — the supporting
/// block the feet rest on, or the cell just below them. A fence, a wall and
/// a fence gate have a 1.5-block collision, so a breeze on one has its feet
/// half a block above that block's own cell, where the cell below them is
/// air and only the supporting block has the material.
pub(super) fn breeze_ground(&self, m: &Mob) -> ([i32; 3], BlockStateId) {
let feet = [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
let in_block = self.world.block(feet[0], feet[1], feet[2]);
if !self.res.facts().is_air(in_block) {
return (feet, in_block);
}
let on_ground = m
.on_ground
.then(|| self.supporting_block(m.position, m.bounds()));
let at = on_ground.flatten().map_or(
[
feet[0],
(m.position.y - f64::from(1.0e-5_f32)).floor() as i32,
feet[2],
],
|p| [p.x, p.y, p.z],
);
(at, self.world.block(at[0], at[1], at[2]))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::{Audio, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::mobs::Mob;
use horizon_core::geometry::Vec3;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// A game on the prepared resources with an empty chunk at (0, 0), the
/// window's player (the camera the dust needs) and one breeze standing
/// with its feet at `feet`.
fn game_with_breeze(feet: Vec3) -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
false,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
// Eight blocks south of the breeze: inside the 32 blocks the dust needs.
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 66.0, 16.5))
.unwrap(),
);
game.particles.camera = [8.5, 66.0, 16.5];
let kind = game.res.mob_types()["minecraft:breeze"].clone();
game.mobs.push(Mob::new(13001, kind, feet, 0.0));
game
}
/// A breeze standing on a fence rests its feet at 64 + 1.5, where its own
/// cell is air: `Breeze.emitGroundParticles` asks `getBlockStateOn` for the
/// supporting block, so the crumbs are the fence's. Every one of them is
/// made by the particle's own generator (`Particle.random`), so the
/// client level's random is where it was before the tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_dust_of_a_breeze_on_a_fence_keeps_the_client_level_random() {
let mut game = game_with_breeze(Vec3::new(8.5, 65.5, 8.5));
let registry = &game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let fence = registry.default_state("minecraft:oak_fence").unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.world.set(8, 64, 8, fence).unwrap();
game.mobs[0].on_ground = true;
assert_eq!(
game.breeze_ground(&game.mobs[0]),
([8, 64, 8], fence),
"the feet's own cell is air; the fence is what holds them up"
);
game.world.random = LegacyRandom::new(1234);
let level = game.world.random.state();
game.tick_breeze_effects();
assert_eq!(
game.particles.made().get("block").copied().unwrap_or(0),
1,
"vanilla emits one crumb of the fence the breeze stands on"
);
assert_eq!(
game.world.random.state(),
level,
"the crumb's constructor draws belong to the particle, not the level"
);
}
/// The control: a breeze standing on a full block's top also crumbles it,
/// and its constructor's draws still leave the client level's random alone.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_dust_of_a_breeze_on_a_full_block_keeps_the_client_level_random() {
let mut game = game_with_breeze(Vec3::new(8.5, 64.0, 8.5));
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.mobs[0].on_ground = true;
game.world.random = LegacyRandom::new(1234);
let level = game.world.random.state();
game.tick_breeze_effects();
assert_eq!(
game.world.random.state(),
level,
"the crumb's constructor draws belong to the particle, not the level"
);
assert_eq!(game.particles.made().get("block").copied().unwrap_or(0), 1);
}
/// `Entity.getOnPos(1.0E-5F)` keeps a fence's, a wall's or a fence gate's
/// own cell while the feet rest on its 1.5-block collision; on a full block
/// it is the block below the feet. Reading 0.2 below the feet, as the dust
/// used to, lands in air on all three.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_ground_of_a_breeze_is_the_fence_wall_or_gate_it_stands_on() {
let mut game = game_with_breeze(Vec3::new(8.5, 65.5, 8.5));
let registry = &game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
game.world.set(8, 63, 8, stone).unwrap();
game.mobs[0].on_ground = true;
for name in [
"minecraft:oak_fence",
"minecraft:cobblestone_wall",
"minecraft:oak_fence_gate",
] {
let state = registry.default_state(name).unwrap();
game.world.set(8, 64, 8, state).unwrap();
assert_eq!(
game.breeze_ground(&game.mobs[0]),
([8, 64, 8], state),
"{name}"
);
}
// The same breeze on the full block's top reads that block.
game.world.set(8, 64, 8, stone).unwrap();
game.mobs[0].position = Vec3::new(8.5, 65.0, 8.5);
assert_eq!(game.breeze_ground(&game.mobs[0]), ([8, 64, 8], stone));
}
}
+1 -1
View File
@@ -2694,7 +2694,7 @@ mod damage_type_registry_tests {
let pack = dir.join("datapacks/model");
let types = pack.join("data/example/damage_type");
std::fs::create_dir_all(&types).unwrap();
std::fs::write(pack.join("pack.mcmeta"), r#"{"pack":{"description":""}}"#).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
types.join("test.json"),
r#"{"message_id":"example.test","scaling":"never","exhaustion":0.0}"#,
+9 -5
View File
@@ -3,7 +3,6 @@
mod admin;
pub(super) mod compute;
mod data;
mod display_names;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
@@ -783,7 +782,13 @@ impl Game<'_> {
let block = Text::translate(&format!("block.{block}"), &[]);
Text::translate("entity.minecraft.falling_block_type", &[block])
}
Target::Mob(id) => self.mob_command_name(id),
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).map_or_else(
|| Text::translate("entity.", &[]),
|m| match self.mob_team_of(m) {
Some(team) => team.formatted_name(m.command_name()),
None => m.command_name(),
},
),
other => {
// Entity.getDisplayName: the CustomName component, else the type.
let custom = super::native_entities::target_id(other)
@@ -2613,10 +2618,9 @@ impl Game<'_> {
if self.res.mob_types().contains_key(&kind) {
// SummonCommand.createEntity loads the mob from `{id:..}`: its yaw
// is the compound's (none, so 0), with no level-random draw.
let id = self
.spawn_mob_spawned(&short, pos, super::creatures::SpawnYaw::Fixed(0.0))
self.spawn_mob_spawned(&short, pos, super::creatures::SpawnYaw::Fixed(0.0))
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
name = self.target_name(Target::Mob(id));
name = Text::translate(&entity_name_key(&kind), &[]);
} else {
match short.as_str() {
"item" => {
@@ -421,16 +421,6 @@ impl Game<'_> {
// random (Entity.random) goes on, as a spawn egg's entity
// data finds it.
slot.random = old.random.clone();
// Entity.load keeps the unsaved Allay.jukeboxPos and
// DATA_DANCING of the entity it loads into.
if slot.species() == horizon_client::mobs::Species::Allay {
slot.extra.flag = old.extra.flag;
if let (Some(st), Some(was)) =
(slot.extra.allay.as_mut(), old.extra.allay.as_ref())
{
st.jukebox = was.jukebox;
}
}
if keep_liked_player {
slot.extra.tame = old.extra.tame;
slot.extra.owner = old.extra.owner;
@@ -1,34 +0,0 @@
//! `Entity.getDisplayName` for the mobs command replies name: the name as its
//! team shows it, with the `show_entity` hover and the UUID insertion.
use super::super::Game;
use horizon_client::text::Text;
impl Game<'_> {
/// `Entity.getDisplayName` for the mob `id`: `PlayerTeam.formatNameForTeam`
/// of `getName()`, then `withHoverEvent(createHoverEvent())` (a `ShowEntity`
/// of the type, UUID and name) and `withInsertion(getStringUUID())`. A mob
/// gets its UUID when it joins the level; one without keeps the bare name.
pub(in crate::game) fn mob_command_name(&self, id: u32) -> Text {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return Text::translate("entity.", &[]);
};
let name = m.command_name();
let shown = match self.mob_team_of(m) {
Some(team) => team.formatted_name(name.clone()),
None => name.clone(),
};
let Some(uuid) = self.persist.uuid_of(id) else {
return shown;
};
let uuid = horizon_client::save::uuid_text(uuid);
let kind = horizon_client::command::args::normalize(&m.kind.id);
shown.with_component_fields(vec![
(
"hover_event".into(),
serde_json::json!({"action": "show_entity", "id": kind, "uuid": uuid,
"name": name.component()}),
),
("insertion".into(), uuid.into()),
])
}
}
@@ -520,12 +520,11 @@ impl Game<'_> {
let Some(pos) = self.target_position(t) else {
return Ok(0);
};
let current = self.target_rotation(t);
let mut look_at_target = None;
let rot = if let Some(Arg::Coords(c)) = ctx.get("rotation") {
// RotateCommand.rotate: relative parts are offsets from the command
// source's rotation (WorldCoordinates.getRotation); the entity's own
// rotation cancels out in Entity.forceSetRotation.
c.rotation(self.source_rotation())
// Relative parts are offsets from the entity's own rotation.
c.rotation(current)
} else {
let mut faced_entity = None;
let to = if ctx.has("entity") {
@@ -441,15 +441,6 @@ impl Game<'_> {
}
Ok(Some(json!({ "ok": true, "flat": r.flat_draws() })))
}
"section-layout" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
let compact = match words.get(1).copied() {
Some("compact") => r.set_packed_layout(true),
Some("wide") => r.set_packed_layout(false),
_ => return Err("section-layout compact|wide".into()),
};
Ok(Some(json!({ "ok": true, "compact": compact })))
}
"section-cull" => {
let r = self.renderer.as_mut().ok_or("no renderer")?;
match words.get(1).copied() {
@@ -325,8 +325,6 @@ fn an_interact_packet_cannot_name_a_mob_the_pick_would_refuse() {
assert!(named_mob_pickable(true, 17, Some(23), Some(29)));
}
#[path = "mob_display_name_tests.rs"]
mod mob_display_name_tests;
#[path = "pet_order_tests.rs"]
mod pet_order_tests;
/// The packet's location is `EntityHitResult.getLocation() -
+3 -37
View File
@@ -261,11 +261,9 @@ fn worn_of(
}
/// The UUID the service knows the player's own model by. On a server, the profile it gave
/// (`profile`), but an offline-mode server's is `nameUUIDFromBytes` of the name (version 3), which
/// the service doesn't know: the signed-in account's (`account`) then, as an online server's would
/// be (#12820). Only the own model: another player's name can be anyone's there. In single player
/// the window's own server (`solo`) names the player by their offline profile too: the account's
/// then, only with *Cosmetics in single player* on (`shown`); off, nothing is asked or drawn.
/// (`profile`). In single player the window's own server (`solo`) names the player by their
/// offline profile, which the service doesn't know: the signed-in account's (`account`) then, only
/// with *Cosmetics in single player* on (`shown`); off, nothing is asked or drawn.
fn own_uuid(
profile: Option<u128>,
solo: bool,
@@ -273,7 +271,6 @@ fn own_uuid(
account: impl FnOnce() -> Option<u128>,
) -> Option<u128> {
match solo {
false if profile.is_some_and(|p| (p >> 76) & 0xf == 3) => account().or(profile),
false => profile,
true if shown && profile.is_some() => account(),
true => None,
@@ -1063,37 +1060,6 @@ mod tests {
assert!(worn.is_none() && asked.is_empty());
}
#[test]
fn an_offline_servers_profile_asks_by_my_account_and_an_online_one_by_its_own() {
const ACCOUNT: u128 = 0x1f2e_3d4c_5b6a_4978_8695_a4b3_c2d1_e0ff;
let offline = horizon_protocol::crypto::offline_uuid("Steve");
let online = 0x069a_79f4_44e9_4726_a5be_fca9_0e38_aaf5;
let signed_in = || Some(ACCOUNT);
// An offline-mode server's profile: the service knows the account, so the account's.
assert_eq!(
own_uuid(Some(offline), false, false, signed_in),
Some(ACCOUNT)
);
// An online server's profile is the account's own already: kept as it is.
assert_eq!(
own_uuid(Some(online), false, false, signed_in),
Some(online)
);
// Signed out: the profile, as before.
assert_eq!(
own_uuid(Some(offline), false, false, || None),
Some(offline)
);
// No profile yet: nothing to ask by.
assert_eq!(own_uuid(None, false, false, signed_in), None);
// Single player still follows its own setting.
assert_eq!(own_uuid(Some(offline), true, false, signed_in), None);
assert_eq!(
own_uuid(Some(offline), true, true, signed_in),
Some(ACCOUNT)
);
}
#[test]
fn single_player_shows_my_cosmetics_only_with_its_setting_and_asks_by_my_account() {
// The window's own server names the player by their offline profile.
@@ -28,12 +28,8 @@ impl Game<'_> {
/// `itemGroup.<mod>.<name>`, else its `title` as a lang key (a mod's own
/// `itemGroup.<mod>`), else `title` as English text.
pub(super) fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
if let Some((name, _, translate)) = &tab.name {
return if *translate {
self.translate(name, name)
} else {
name.clone()
};
if let Some((name, _)) = &tab.name {
return name.clone();
}
match horizon_client::creative::item_group_key(&tab.id) {
Some(key) => self.translate(key, &tab.title),
@@ -295,13 +291,6 @@ impl Game<'_> {
if let Some(lines) = effect_tip {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
// CreativeModeInventoryScreen.checkTabHovering: a tab says its name.
if let Some(i) = Creative::hovered_tab(&tabs, (ox, oy), self.cursor)
&& let Some(t) = tabs.get(i)
{
let title = self.creative_tab_title(t);
self.tooltip(batch, &title, self.cursor.0, self.cursor.1);
}
}
pub(super) fn creative_trash_hovered(batch: &HudBatch, cursor: (f32, f32)) -> bool {
let (ox, oy) = Self::creative_origin(batch);
+1 -1
View File
@@ -3059,7 +3059,7 @@ impl Game<'_> {
// `CrossbowItem.createProjectile`: a rocket, from 0.15 under the eyes.
let mut at = arrow.position;
at.y -= f64::from(0.15f32) - 0.1;
self.launch_angled_rocket(at, &ammo, arrow.owner, |_| arrow.velocity);
self.launch_angled_rocket(at, arrow.velocity, &ammo, arrow.owner);
} else {
Self::arrow_from_ammo(&ammo, &mut arrow);
self.launch_mob_projectile(arrow);
+4 -5
View File
@@ -10,6 +10,7 @@ use horizon_client::{
items::ItemStack,
mobs::Who,
projectiles::{Projectile, ProjectileKind},
random_source,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -341,12 +342,10 @@ impl Game<'_> {
let rocket = &*ammo.item == FIREWORK_ROCKET;
if rocket {
let at = Vec3::new(eye[0], eye[1] - f64::from(0.15f32), eye[2]);
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut random_source::entity(), dir.to_array(), power, 1.0);
// Its first tick posts the shot (Projectile.tick).
self.launch_angled_rocket(at, ammo, Some(Who::Player), |own| {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(own, dir.to_array(), power, 1.0);
shot.velocity
});
self.launch_angled_rocket(at, shot.velocity, ammo, Some(Who::Player));
}
if !rocket {
let effect = super::dispenser::tipped_effect(ammo).or_else(|| {
+5 -73
View File
@@ -90,22 +90,6 @@ pub(super) struct DialogState {
warning: bool,
}
impl DialogState {
/// `Minecraft.pause` over these screens: `DialogScreen.isPauseScreen` is
/// the dialog's own `pause` (`CommonDialogData.pause`, true by default),
/// `WaitingForResponseScreen` overrides it false, and the warning above
/// the dialog is a `ConfirmScreen`, which inherits `Screen`'s true.
pub(super) fn pauses_world(&self) -> bool {
if self.warning {
return true;
}
if self.waiting.is_some() {
return false;
}
self.open.as_ref().is_some_and(|open| open.dialog.pause)
}
}
/// `CycleButton.cycleValue`: the next option (`delta` 1) or the one before
/// it, wrapping.
fn cycle(open: &mut Open, input: usize, delta: i32) {
@@ -573,19 +557,17 @@ impl Game<'_> {
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
// AbstractStringWidget: only the styled glyph under the
// pointer requests a hand, not the whole message widget.
let under = |wanted: fn(&horizon_client::text::Style) -> bool| {
horizon_client::dialog_screen::line_style_at(
if (!clipped || in_area(cursor.1))
&& let Some(style) = horizon_client::dialog_screen::line_style_at(
&atlas.font,
&text,
x,
y,
cursor,
wanted,
)
};
if !clipped || in_area(cursor.1) {
widgets::widget_cursor(under(|s| s.click.is_some()).is_some(), true);
if let Some(hover) = under(|s| s.hover.is_some()).and_then(|s| s.hover) {
{
widgets::widget_cursor(style.click.is_some(), true);
if let Some(hover) = style.hover {
tooltip = Some(*hover);
}
}
@@ -916,7 +898,6 @@ impl Game<'_> {
x,
y,
cursor,
|style| style.click.is_some(),
)
.and_then(|style| style.click);
}
@@ -1278,52 +1259,3 @@ mod background_tests {
}
}
}
#[cfg(test)]
mod pause_tests {
use super::*;
fn dialog(pause: bool) -> Dialog {
let s = |value: &str| Tag::String(value.into());
Dialog::parse(&Tag::compound([
("type", s("notice")),
("title", s("Hi")),
("pause", Tag::Byte(i8::from(pause))),
]))
.unwrap()
}
fn state(pause: bool) -> DialogState {
DialogState {
open: Some(Open {
dialog: dialog(pause),
values: Vec::new(),
fields: Vec::new(),
focus: None,
scroll: ui::Scroll::default(),
dragging: None,
selecting: None,
}),
waiting: None,
warning: false,
}
}
/// `DialogScreen.isPauseScreen` is `dialog.common().pause()`, true by
/// default, so a server's dialog stops a single-player world unless it
/// asks otherwise. The waiting screen and the warning override it.
#[test]
fn a_dialog_pauses_the_world_only_while_its_pause_says_so() {
assert!(state(true).pauses_world());
assert!(!state(false).pauses_world());
// No dialog screen at all.
assert!(!DialogState::default().pauses_world());
// DialogScreen.WarningScreen extends ConfirmScreen: true.
let mut warning = state(false);
warning.warning = true;
assert!(warning.pauses_world());
// WaitingForResponseScreen.isPauseScreen is false.
let mut waiting = state(true);
waiting.waiting = Some(Instant::now());
assert!(!waiting.pauses_world());
}
}
+5 -23
View File
@@ -1097,16 +1097,6 @@ impl Game<'_> {
count: 1,
..stack.clone()
};
// A rocket makes its own random in `launch_angled_rocket`: the constructor's draws
// and then the shot's spread come from that one.
if item == "minecraft:firework_rocket" {
self.launch_angled_rocket(at, &one, None, |own| {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(own, dir, power, uncertainty);
shot.velocity
});
return;
}
// Projectile.shoot spreads the shot with the projectile's own random.
let mut own = random_source::entity();
match item.as_str() {
@@ -1117,6 +1107,11 @@ impl Game<'_> {
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
}
"minecraft:firework_rocket" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_angled_rocket(at, shot.velocity, &one, None);
}
_ => {
let kind = match item.as_str() {
"minecraft:arrow" => ProjectileKind::Arrow {
@@ -1253,12 +1248,7 @@ impl Game<'_> {
boat = boat.with_chest();
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
let (id, kind, yaw) = (boat.id, boat.entity_type(), boat.yaw);
self.boats.push(boat);
// BoatDispenseItemBehavior: createDefaultStackConfig with no user,
// then setYRot(direction.toYRot()).
self.apply_vehicle_stack_config(super::commands::Target::Boat(id), &kind, &stack, false);
self.set_boat_yaw(id, yaw);
if self.serving.is_some() || self.options.dedicated.is_some() {
let boat = self.boats.last().unwrap();
self.native_vehicle_create(
@@ -1305,14 +1295,6 @@ impl Game<'_> {
);
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.carts.push(cart);
// AbstractMinecart.createMinecart: createDefaultStackConfig with no user.
let id = self.next_entity;
self.apply_vehicle_stack_config(
super::commands::Target::Cart(id),
kind.id(),
&stack,
false,
);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
+12 -3
View File
@@ -3108,8 +3108,18 @@ impl Game<'_> {
if hurts {
self.note_dragon_attacker("minecraft:mob_attack");
}
let id = self.health_resources.damage.id("minecraft:mob_attack");
let canceled = self.push_seen_with_equipment_hurt(push, id.filter(|_| hurts), 5.0);
let (health, rules) = (&self.health_resources, self.world.health_rules);
let id = health.damage.id("minecraft:mob_attack");
let mut canceled = false;
if let Some(p) = self.player.as_mut() {
push_seen_with_hurt(p, push, |p| {
if let Some(id) = id.filter(|_| hurts)
&& let Ok(o) = p.hurt_blocking(health, rules, id, 5.0, None)
{
canceled = o.canceled;
}
});
}
self.combat.forget_noted(canceled);
if !sitting
&& !hurt_lately
@@ -4805,7 +4815,6 @@ use death_render::{dissolve_color, dissolve_mask, dragon_rays};
/// reaches the players tracking them, not them. So the push moves the player
/// only together with a hurt that lands outside the hurt cooldown, and is
/// dropped otherwise.
#[cfg(test)]
fn push_seen_with_hurt(
p: &mut horizon_client::motion::PlayerMotion,
push: Vec3,
@@ -108,12 +108,7 @@ fn a_ticking_gateway_counts_its_age_in_its_block_entity() {
game.world
.chunks
.insert((2, 0), Arc::new(Chunk::empty(2, 0)));
// A dedicated server with nobody on it: the console runs the command, on
// the command registries (as block_entity_anims' gateway test does).
game.build_registries();
assert!(
game.as_console(|game| game.run_command("setblock 40 64 8 minecraft:end_gateway{Age:0L}"))
);
assert!(game.run_command("setblock 40 64 8 minecraft:end_gateway{Age:0L}"));
game.update_chunk_tickets();
assert!(game.block_ticking_area()(40, 8));
// What `/data get block` and the chunk save encode (`TheEndGatewayBlockEntity.saveAdditional`).
-8
View File
@@ -247,11 +247,6 @@ pub(super) trait Entity: EntityRules {
fn portal_size(&self) -> Option<(f32, f32)> {
None
}
/// Whether [`Self::portal_size`] is Some, without working the size out (a
/// mob's depends on its age, scale and pose).
fn sweeps_portals(&self) -> bool {
self.portal_size().is_some()
}
/// `Entity.canUsePortal(false)` for a kind in `portal_size`. A kind that
/// cannot stays tracked, so its cooldown and process run out.
fn can_use_portal(&self, _game: &Game<'_>) -> bool {
@@ -1110,9 +1105,6 @@ impl Entity for Mob {
fn portal_size(&self) -> Option<(f32, f32)> {
Some((self.width(), self.height()))
}
fn sweeps_portals(&self) -> bool {
true
}
fn can_use_portal(&self, game: &Game<'_>) -> bool {
game.mob_can_use_portal(self)
}
+14 -59
View File
@@ -272,35 +272,18 @@ pub(super) fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
.and_then(|d| d.fireworks.clone())
.unwrap_or((1, Vec::new()))
}
/// `FireworkRocketEntity(Level, double, double, double, ItemStack)`, after the UUID:
/// the motion's two triangles, then the lifetime's two `nextInt`s, all from `random`.
fn rocket_draws(random: &mut horizon_core::random::LegacyRandom, flight: u8) -> (Vec3, i32) {
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
let velocity = Vec3::new(tri(random), 0.05, tri(random));
let lifetime = 10 * (1 + i32::from(flight))
+ random.next_int_bound(6).unwrap_or(0)
+ random.next_int_bound(7).unwrap_or(0);
(velocity, lifetime)
}
impl Game<'_> {
/// A rocket built from `own`, the rocket's own random (`Entity.random`): the
/// UUID, the motion and the lifetime draw from it in that order, and a rocket
/// that is then shot (`Projectile.shoot`) goes on drawing from the same one.
pub(super) fn new_rocket(
&mut self,
at: Vec3,
stack: &ItemStack,
attached: bool,
own: &mut horizon_core::random::LegacyRandom,
) -> Rocket {
pub(super) fn new_rocket(&mut self, at: Vec3, stack: &ItemStack, attached: bool) -> Rocket {
let (flight, explosions) = fireworks_of(stack);
// Entity.<init>: the id comes from the one counter at construction, the UUID
// from the random's first two longs.
// FireworkRocketEntity: motion and lifetime come from the rocket's own random.
let r = &mut random_source::entity();
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
let velocity = Vec3::new(tri(r), 0.05, tri(r));
let lifetime = 10 * (1 + i32::from(flight))
+ r.next_int_bound(6).unwrap_or(0)
+ r.next_int_bound(7).unwrap_or(0);
// Entity.<init>: the id comes from the one counter at construction.
let id = self.take_entity_id();
self.persist
.uuids
.insert(id, horizon_client::save::insecure_uuid(own));
let (velocity, lifetime) = rocket_draws(own, flight);
Rocket {
rotation: [0.0, 0.0],
join: Default::default(),
@@ -335,7 +318,7 @@ impl Game<'_> {
hit.point[1] + f64::from(hit.face[1]) * 0.15,
hit.point[2] + f64::from(hit.face[2]) * 0.15,
);
let mut rocket = self.new_rocket(at, stack, false, &mut random_source::entity());
let mut rocket = self.new_rocket(at, stack, false);
rocket.owner = Some(Who::Player);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
@@ -347,19 +330,15 @@ impl Game<'_> {
}
/// A rocket shot at an angle (`DATA_SHOT_AT_ANGLE`): out of a dispenser
/// (`FireworkRocketItem.asProjectile`) or a crossbow.
///
/// `shoot` gives the shot's velocity from the rocket's own random, after the
/// constructor's draws (`asProjectile`, then `spawnProjectileUsingShoot`).
pub(super) fn launch_angled_rocket(
&mut self,
at: Vec3,
velocity: Vec3,
stack: &ItemStack,
owner: Option<Who>,
shoot: impl FnOnce(&mut horizon_core::random::LegacyRandom) -> Vec3,
) {
let mut own = random_source::entity();
let mut rocket = self.new_rocket(at, stack, false, &mut own);
rocket.velocity = shoot(&mut own);
let mut rocket = self.new_rocket(at, stack, false);
rocket.velocity = velocity;
rocket.angled = true;
rocket.owner = owner;
rocket.guest = self.lan_acting.filter(|_| owner == Some(Who::Player));
@@ -434,7 +413,7 @@ impl Game<'_> {
self.play_player_bound_sound("minecraft:item.lead.break", 6, 1.0, 1.0, false);
}
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let mut rocket = self.new_rocket(at, stack, true, &mut random_source::entity());
let mut rocket = self.new_rocket(at, stack, true);
rocket.owner = Some(Who::Player);
rocket.guest = self.lan_acting;
self.rockets.push(rocket);
@@ -863,30 +842,6 @@ fn use_on_passes(fall_flying: bool) -> bool {
#[cfg(test)]
mod tests {
/// A rocket draws its UUID's two longs first, then the motion's two triangles
/// (four doubles), then `nextInt(6)` and `nextInt(7)` for the lifetime, all from
/// the one random. The expected values come from `java.util.Random` replayed
/// outside the game: a rocket whose UUID draw were skipped would read 30, 22
/// and 26 here.
#[test]
fn a_rockets_lifetime_follows_its_uuid_and_motion_draws() {
use horizon_core::random::LegacyRandom;
for (seed, flight, lifetime) in [
(1, 1, 26),
(12_345, 1, 26),
(987_654_321, 1, 25),
(1, 3, 46),
] {
let mut own = LegacyRandom::new(seed);
horizon_client::save::insecure_uuid(&mut own);
assert_eq!(
super::rocket_draws(&mut own, flight).1,
lifetime,
"seed {seed}"
);
}
}
/// A flying rocket's vertices carry its own id for frame generation's and a temporal
/// upscaler's motion vectors (`Frame::entity_motion`), so the motion matched between
/// two frames is its flight, not only the camera's.
@@ -54,33 +54,6 @@ impl Game<'_> {
.finish_equipment_hurt(&self.health_resources, hit)?;
Ok((outcome, actual))
}
/// `push_seen_with_hurt` for the host: the push is kept only when the hurt
/// marks the velocity as seen, and the hurt goes through
/// `hurt_player_equipment` so the worn armor loses durability. Answers
/// whether a native hook canceled the hurt.
pub(super) fn push_seen_with_equipment_hurt(
&mut self,
push: Vec3,
source: Option<horizon_world::damage_source::DamageTypeId>,
amount: f32,
) -> bool {
let Some(p) = self.player.as_mut() else {
return false;
};
let before = p.velocity;
let marked_before = std::mem::take(&mut p.health.sync_velocity);
p.velocity = before.add_vector(push);
let canceled = source
.and_then(|id| self.hurt_player_equipment(id, amount, None, false, 0).ok())
.is_some_and(|(o, _)| o.canceled);
if let Some(p) = self.player.as_mut() {
if !p.health.sync_velocity {
p.velocity = before;
}
p.health.sync_velocity |= marked_before;
}
canceled
}
/// The armor hits among a tick's motion events (`Player.hurtArmor` from
/// `getDamageAfterArmorAbsorb`), whatever the damage: a mob, an explosion,
/// a cactus, lava, a campfire.
+4 -150
View File
@@ -708,20 +708,6 @@ impl Game<'_> {
}
for &(pos, _) in &view.parts {
self.container_changed(pos);
// `ShelfBlockEntity.setChanged()` (the no-argument override posts
// `BLOCK_ACTIVATE`): a container writing a shelf's slot is heard.
if matches!(
self.world.block_entities.get(&pos),
Some(BlockEntity::Shelf(_))
) {
let state = self.world.block(pos.0, pos.1, pos.2);
self.post_block_game_event(
"minecraft:block_activate",
[pos.0, pos.1, pos.2],
None,
Some(state),
);
}
}
}
/// `BlockEntity.setChanged` at `pos`: comparators re-read the container.
@@ -913,9 +899,6 @@ impl Game<'_> {
}
/// Remove one item from a slot (`removeItem(slot, 1)`).
fn take_one(&mut self, view: &View, slot: usize) -> Option<ItemStack> {
if view.kind == Kind::Pot {
return self.pot_take_one(view.parts[0].0);
}
let target = self.slot_mut_at(view, slot)?;
let current = target.as_mut().filter(|s| s.count > 0)?;
let one = ItemStack {
@@ -928,56 +911,8 @@ impl Game<'_> {
}
Some(one)
}
/// `ContainerSingleItem.removeItem(0, 1)` on a decorated pot's saved slot.
fn pot_take_one(&mut self, pos: Pos) -> Option<ItemStack> {
let mut tag = spill::saved_entity(&self.world, [pos.0, pos.1, pos.2])?;
let mut item = tag.get("item").and_then(save::item_from_nbt)?;
if item.count == 0 {
return None;
}
let one = ItemStack {
count: 1,
..item.clone()
};
item.count -= 1;
self.pot_store(pos, &mut tag, (item.count > 0).then_some(item));
Some(one)
}
/// Undo `pot_take_one` after a failed move.
fn pot_put_back(&mut self, pos: Pos, one: ItemStack) {
let Some(mut tag) = spill::saved_entity(&self.world, [pos.0, pos.1, pos.2]) else {
return;
};
let item = match tag.get("item").and_then(save::item_from_nbt) {
Some(mut current) => {
current.count += one.count;
current
}
None => one,
};
self.pot_store(pos, &mut tag, Some(item));
}
/// Write a pot's saved item slot, keeping the tag's other metadata.
fn pot_store(&mut self, pos: Pos, tag: &mut Tag, item: Option<ItemStack>) {
if let Tag::Compound(map) = tag {
match item {
Some(item) => {
map.insert("item".into(), save::item_to_nbt(&item));
}
None => {
map.remove(&"item".into());
}
}
}
spill::put_saved_entity(&mut self.world, [pos.0, pos.1, pos.2], tag.clone());
self.mark_block_entity_updated(pos);
}
/// Undo `take_one` after a failed move.
fn put_back(&mut self, view: &View, slot: usize, one: ItemStack) {
if view.kind == Kind::Pot {
self.pot_put_back(view.parts[0].0, one);
return;
}
if let Some(target) = self.slot_mut_at(view, slot) {
match target {
Some(current) => current.count += one.count,
@@ -1068,11 +1003,7 @@ impl Game<'_> {
.assets
.registry
.state(self.world.block(above.0, above.1, above.2))
.is_none_or(|s| {
// Composters and decorated pots are containers without a
// modeled block entity.
s.name != "minecraft:composter" && s.name != "minecraft:decorated_pot"
})
.is_none_or(|s| s.name != "minecraft:composter")
{
return;
}
@@ -1343,7 +1274,7 @@ impl Game<'_> {
/// (`getSourceContainerOrHandler`). `None` when there is no container.
fn suck_from_container(&mut self, above: Pos, at: [f64; 3], hopper: &View) -> Option<bool> {
let c = self.containers();
let Some(source) = c.hopper_insertion_view(above) else {
let Some(source) = c.view(above, false) else {
let id = self.entity_container_at(at)?;
self.native_hopper_inventory_search(
[
@@ -1408,10 +1339,10 @@ impl Game<'_> {
for slot in source.slots_for_face_iter(Some(Direction::Down)) {
{
let c = self.containers();
let Some(stack) = c.insertion_item(source, slot).map(Cow::into_owned) else {
let Some(stack) = c.get(source, slot) else {
continue;
};
if !c.can_take(source, slot, &stack, Direction::Down) {
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
}
@@ -2364,81 +2295,4 @@ mod pot_hopper_insertion_tests {
"actual hopper/pot transfer missing: {failures:?}"
);
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES and scratch homes/cwd"]
fn actual_hopper_pulls_from_the_saved_pot_slot() {
let mut game = server_game();
game.build_registries();
game.set_allow_commands(true);
game.block_ticks
.add_empty_chunk(BlockPos::new(0, 0, 0).chunk())
.unwrap();
assert!(game.run_command("fill 0 199 0 15 199 15 stone"));
assert!(
game.run_command(
"setblock 5 201 4 decorated_pot{item:{id:\"minecraft:wheat\",count:2}}"
)
);
assert!(
game.run_command(
"setblock 5 200 4 hopper[facing=down,enabled=true]{TransferCooldown:0}"
)
);
let start = game.world.game_time;
for elapsed in 1..=9 {
game.world.game_time = start + elapsed;
game.tick_hopper((5, 200, 4));
}
let Some(BlockEntity::Chest(slots)) = game.world.block_entities.get(&(5, 200, 4)) else {
panic!("hopper inventory missing");
};
let held: u32 = slots.iter().flatten().map(|s| s.count).sum();
assert_eq!(held, 2, "the hopper took both items");
let tag = spill::saved_entity(&game.world, [5, 201, 4]).expect("saved pot");
assert!(tag.get("item").is_none(), "the pot is empty");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::native_machines::tests::server_game;
/// `ShelfBlockEntity.setChanged` (no arguments) posts `BLOCK_ACTIVATE`:
/// a container writing a shelf's slot is a block activation.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_container_writing_a_shelf_posts_block_activate() {
let mut game = server_game();
let key = (2, 4, 3);
let state = game
.pack
.assets
.registry
.default_state("minecraft:oak_shelf")
.unwrap();
game.world.set(key.0, key.1, key.2, state).unwrap();
game.world
.block_entities
.put(key, BlockEntity::Shelf(Box::default()));
let view = game
.containers()
.view(key, false)
.expect("the shelf's view");
super::super::sculk::TEST_POSTED_EVENTS.with(|e| *e.borrow_mut() = Some(Vec::new()));
game.view_changed(&view);
super::super::sculk::TEST_POSTED_EVENTS.with(|e| {
assert!(
e.borrow().as_ref().unwrap().contains(&(
"minecraft:block_activate",
[2.5, 4.5, 3.5],
None
)),
"{:?}",
e.borrow()
);
});
}
}
+52 -194
View File
@@ -469,12 +469,11 @@ impl Game<'_> {
[x, y, z]
}
}
/// `AbstractMinecart.isRedstoneConductor`: the block state's own flag
/// (soul sand conducts without a full collision shape).
fn conductor(&self, x: i32, y: i32, z: i32) -> bool {
self.res.behaviours().facts(self.world.block(x, y, z)).flags
& horizon_world::level::data::state_flags::CONDUCTOR
!= 0
self.res
.facts()
.get(self.world.block(x, y, z))
.full_collision
}
/// `OldMinecartBehavior.getPos`: the point on the rail under (x, y, z).
fn rail_pos(&self, x: f64, y: f64, z: f64) -> Option<[f64; 3]> {
@@ -530,9 +529,11 @@ impl Game<'_> {
/// collision, which sets a TNT minecart off.
fn cart_entity_collisions(&self, c: &Minecart) -> Vec<Aabb> {
let near = c.bounds().inflate(Vec3::new(2.0, 2.0, 2.0));
// Every moving cart asks each tick, and the box turns nearly every entity of
// the level away: it is tested first, and only the mobs near it are read.
// Every cart asks about every entity of the level each tick, and the box
// turns nearly all of them away: it is tested first, before a mob's
// pushable state, and only the boxes kept are collected.
let close = |b: &Aabb| b.intersects(near);
let reach = self.vehicle_mob_reach();
let mut boxes: Vec<Aabb> = self
.carts
.iter()
@@ -545,13 +546,22 @@ impl Game<'_> {
.map(super::boats::Boat::bounds)
.filter(close),
)
.chain(
self.mobs
.iter()
.filter(|m| reach.is_none_or(|r| r.may_meet(m, near)))
.map(|m| (m, m.bounds()))
.filter(|(m, b)| {
close(b)
&& !m.removed
&& m.extra.mount != Some(c.id)
&& (m.pushable()
|| m.species() == horizon_client::mobs::Species::Shulker
&& m.alive())
})
.map(|(_, b)| b),
)
.collect();
self.mobs_meeting(near, |_, m, b| {
let counts = m.pushable() || m.species() == Species::Shulker && m.alive();
if !m.removed && m.extra.mount != Some(c.id) && counts {
boxes.push(b);
}
});
if self.riding != Some(c.id)
&& let Some(p) = self.player.as_ref()
&& !self.spectator()
@@ -690,7 +700,12 @@ impl Game<'_> {
/// sits in it. `getSlowdownFactor` (0.997) and the 0.75 speed scale key on
/// this, not on who rides.
fn cart_is_vehicle(&self, id: u32) -> bool {
self.riding == Some(id) || self.ridden_by_guest(id) || self.mob_rides(id)
self.riding == Some(id)
|| self.ridden_by_guest(id)
|| self
.mobs
.iter()
.any(|m| !m.removed && m.extra.mount == Some(id))
}
/// `OldMinecartBehavior.moveAlongTrack`.
fn move_along_track(
@@ -699,8 +714,7 @@ impl Game<'_> {
pos: [i32; 3],
rail: &Rail,
intent: [f64; 2],
slot: usize,
) {
) -> (Vec3, Vec3) {
c.fall_distance = 0.0;
let ridden = self.cart_is_vehicle(c.id);
let (x, z) = (c.position.x, c.position.z);
@@ -771,11 +785,11 @@ impl Game<'_> {
0.0,
(scale * c.velocity.z).clamp(-max, max),
);
// Entity.move records this step at the rail's top height; the tick's
// applyEffectsFromBlocks sweeps it, whatever the cart does after.
let step_from = c.position;
self.move_cart(c, delta);
// 🤖 Agent: `AbstractMinecart.move` runs applyEffectsFromBlocks at
// once, the cart still at the rail's top height (below it is lowered).
let here = c.position;
self.cart_inside_blocks(c, slot, here, here);
let step = (step_from, c.position);
let at = |e: [i32; 3], c: &Minecart| {
c.position.x.floor() as i32 - pos[0] == e[0]
&& c.position.z.floor() as i32 - pos[2] == e[2]
@@ -825,6 +839,7 @@ impl Game<'_> {
}
}
}
step
}
/// `Entity.checkInsideBlocks` for a recorded move of the cart from `from`
/// to `to`: the entity-sensing blocks (plates, buttons, tripwires, detector
@@ -1352,10 +1367,11 @@ impl Game<'_> {
// applyGravity
c.velocity.y -= c.get_gravity();
let pos = self.rail_block(c.position);
let mut step = None;
if self.minecart_improvements() {
self.move_along_track_new(c, intent);
} else if let Some(rail) = self.rail(pos[0], pos[1], pos[2]) {
self.move_along_track(c, pos, &rail, intent, slot);
step = Some(self.move_along_track(c, pos, &rail, intent));
if &*rail.name == "minecraft:activator_rail" {
self.activate_cart(c, rail.powered);
}
@@ -1366,8 +1382,6 @@ impl Game<'_> {
// The old behavior derives yaw once per tick; the experiment has
// already recorded each step's yaw and pitch.
if !self.minecart_improvements() {
// OldMinecartBehavior.tick: setXRot(0.0F) before the yaw update.
c.pitch = 0.0;
let (xd, zd) = (c.previous.x - c.position.x, c.previous.z - c.position.z);
if xd * xd + zd * zd > 0.001 {
c.yaw = horizon_core::geometry::trig::atan2_degrees_double(zd, xd);
@@ -1381,13 +1395,13 @@ impl Game<'_> {
c.flipped = !c.flipped;
}
c.yaw %= 360.0;
c.pitch %= 360.0;
}
if !self.minecart_improvements() {
self.cart_block_effects(c);
// OldMinecartBehavior.tick: applyEffectsFromBlocks() once settled.
let here = c.position;
self.cart_inside_blocks(c, slot, here, here);
// applyEffectsFromBlocks over the step moveAlongTrack recorded.
if let Some((from, to)) = step {
self.cart_inside_blocks(c, slot, from, to);
}
}
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
// AbstractMinecart.tick's updateFluidInteraction. The experiment still
@@ -1617,30 +1631,27 @@ impl Game<'_> {
}
self.push_carts_from_mobs(&cells);
}
/// `AbstractMinecart.push(mob)` from each mob walking into a cart (`LivingEntity.pushEntities`
/// -> `doPush`): the cart gets the push and the mob a quarter of it back (`Entity.push`, which
/// needs the mob to be pushable and to carry no one). Bats push nothing (`Bat.pushEntities`),
/// nor does a mob whose box reaches no cart: its box is worked out only if it may.
fn push_carts_from_mobs(&mut self, cells: &SeenGrid) {
let (teams, mut near) = (self.mob_teams(), Vec::new());
// The mobs a cart is near, or every mob, each then asked which carts it is near.
let slots = self.mobs_near_carts();
let reach = slots.is_none().then(|| self.vehicle_mob_reach()).flatten();
let slots = slots.unwrap_or_else(|| (0..self.mobs.len() as u32).collect());
/// `AbstractMinecart.push(mob)` from each mob walking into a cart
/// (`LivingEntity.pushEntities` -> `doPush`): the cart gets the push and
/// the mob a quarter of it back (`Entity.push`, which needs the mob to be
/// pushable and to carry no one). Bats push nothing (`Bat.pushEntities`).
fn push_carts_from_mobs(&mut self, cells: &horizon_client::mobs::seen_grid::SeenGrid) {
let teams = self.mob_teams();
// (mob index, feet, box)
let pushers: Vec<(usize, Vec3, Aabb)> = slots
.into_iter()
.map(|j| (j as usize, &self.mobs[j as usize]))
let pushers: Vec<(usize, Vec3, Aabb)> = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| {
!m.removed
&& m.alive()
&& m.species() != Species::Bat
&& reach.is_none_or(|r| self.mob_may_meet_cart(r, m, cells, &mut near))
&& pushable_by(teams.of(Who::Mob(m.id)), None, true)
})
.map(|(j, m)| (j, m.position, m.bounds()))
.collect();
drop(teams);
let mut near = Vec::new();
for (j, feet, bb) in pushers {
if !cells.candidates(bb, &mut near) {
near.extend(0..self.carts.len() as u32);
@@ -2120,51 +2131,6 @@ impl Game<'_> {
p.velocity = Vec3::ZERO;
}
}
/// `EntityType.createDefaultStackConfig` on a vehicle an item made: the
/// item's custom name and custom data (`applyComponentsFromItemStack`:
/// `Entity.applyImplicitComponent`'s two, saved as `CustomName` and
/// `data`), then its
/// `minecraft:entity_data` loaded over the vehicle (`updateCustomEntityTag`)
/// when it names this type. An op-only type (command block and spawner
/// minecarts) takes it from an op player only, never from a dispenser
/// (`by_player` false).
pub(super) fn apply_vehicle_stack_config(
&mut self,
target: super::commands::Target,
kind: &str,
stack: &ItemStack,
by_player: bool,
) {
use horizon_world::nbt::Tag;
let name = stack.data().and_then(|d| d.custom_name.clone());
let custom = stack
.data()
.and_then(|d| d.other("minecraft:custom_data"))
.cloned();
let op = by_player && self.acting_player_is_op();
let data =
horizon_client::block_entity_item::spawn_egg_entity_data(stack, kind, op).cloned();
if name.is_none() && custom.is_none() && data.is_none() {
return;
}
let Some(mut own) = self.entity_nbt(target) else {
return;
};
if let Tag::Compound(map) = &mut own {
if let Some(name) = name {
map.insert("CustomName".into(), name);
}
if let Some(custom) = custom {
map.insert("data".into(), custom);
}
}
let tag = match &data {
Some(data) => horizon_client::data_cmd::merged(&own, data),
None => own,
};
// The vehicle's own compound, its name and data merged in: it loads back.
let _ = self.apply_entity_nbt(target, tag);
}
/// `MinecartItem.useOn`: a cart on the clicked rail.
pub(super) fn place_minecart(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let Some(kind) = CartKind::from_id(&stack.item) else {
@@ -2185,8 +2151,6 @@ impl Game<'_> {
.modified
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
self.carts.push(cart);
let id = self.next_entity;
self.apply_vehicle_stack_config(super::commands::Target::Cart(id), kind.id(), stack, true);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
@@ -3536,29 +3500,6 @@ mod tnt_impact_tests {
false
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_powered_rail_pushes_a_resting_cart_away_from_soul_sand() {
let mut game = server_game();
let registry = game.pack.assets.registry;
let rail = registry.default_state("minecraft:powered_rail").unwrap();
let mut state = registry.state(rail).unwrap().clone();
state.properties.insert("shape".into(), "east_west".into());
state.properties.insert("powered".into(), "true".into());
let rail = registry.id(&state).unwrap();
game.world.set(5, 64, 8, rail).unwrap();
for (block, pushed) in [
("minecraft:soul_sand", true),
("minecraft:stone", true),
("minecraft:oak_slab", false),
] {
let id = registry.default_state(block).unwrap();
game.world.set(4, 64, 8, id).unwrap();
let push = game.redstone_direction([5, 64, 8], super::Shape::EastWest);
assert_eq!(push.x == 1.0, pushed, "{block}");
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tnt_cart_stopped_head_on_by_a_wall_does_not_blow_up() {
@@ -3627,89 +3568,6 @@ mod tnt_impact_tests {
registry.state(game.world.block(6, 65, 8))
);
}
/// A cart blocked by a fence on a slope sits below the plate on the fence,
/// but `AbstractMinecart.move` sweeps its blocks while it is still at the
/// rail's top height, so the plate clocks on and off.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_cart_resting_against_a_fence_keeps_toggling_the_plate_above_it() {
let mut game = server_game();
let registry = game.pack.assets.registry;
let stone = registry.default_state("minecraft:stone").unwrap();
let fence = registry.default_state("minecraft:oak_fence").unwrap();
let plate = registry
.default_state("minecraft:oak_pressure_plate")
.unwrap();
let rail = registry.default_state("minecraft:rail").unwrap();
let mut slope = registry.state(rail).unwrap().clone();
slope
.properties
.insert("shape".into(), "ascending_east".into());
let slope = registry.id(&slope).unwrap();
for x in 0..16 {
for z in 0..16 {
game.world.set(x, 62, z, stone).unwrap();
}
}
game.world.set(0, 64, 8, fence).unwrap();
game.world.set(0, 65, 8, plate).unwrap();
game.world.set(1, 63, 8, stone).unwrap();
game.world.set(1, 64, 8, slope).unwrap();
game.world.set(2, 64, 8, stone).unwrap();
game.world.set(2, 65, 8, rail).unwrap();
game.carts.push(Minecart::new(
1,
CartKind::Rideable,
Vec3::new(1.9, 64.9, 8.5),
));
// Scheduled ticks and carts only run in lit, surrounded chunks near a
// player.
game.settings.simulation_distance = 4;
game.settings.render_distance = 12;
game.player = Some(
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(8.5, 70.0, 12.5))
.unwrap(),
);
for cx in -2..=2 {
for cz in -2..=2 {
if (cx, cz) != (0, 0) {
let mut chunk = horizon_client::chunk::Chunk::empty(cx, cz);
chunk.lit = true;
game.world
.chunks
.insert((cx, cz), std::sync::Arc::new(chunk));
game.block_ticks
.add_empty_chunk(horizon_core::position::ChunkPos::new(cx, cz))
.unwrap();
}
}
}
let mut center = (**game.world.chunks.get(&(0, 0)).unwrap()).clone();
center.lit = true;
game.world
.chunks
.insert((0, 0), std::sync::Arc::new(center));
game.update_chunk_tickets();
let mut edges = 0;
let mut last = false;
for _ in 0..200 {
game.world.game_time += 1;
game.tick_carts_now();
game.tick_blocks().unwrap();
let on = registry
.state(game.world.block(0, 65, 8))
.unwrap()
.properties
.get("powered")
.is_some_and(|v| v == "true");
if on != last {
edges += 1;
last = on;
}
}
assert!(edges >= 4, "the plate changed {edges} times in 200 ticks");
}
}
#[cfg(test)]
@@ -1,79 +0,0 @@
//! A mob named in a command reply carries `Entity.getDisplayName`'s hover and
//! insertion (#14220): `HoverEvent.ShowEntity` of its type, UUID and name, and
//! its UUID string to insert.
use super::*;
use crate::game::commands::Target;
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
for command in ["forceload add 0 0", "fill 0 63 0 15 63 15 stone"] {
assert!(game.run_command(command), "{command}");
}
game
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_summoned_mobs_name_shows_the_entity_on_hover() {
let mut game = game();
assert!(game.run_command("summon minecraft:pig 4.5 64 4.5 {NoAI:1b}"));
let id = game.mobs.last().unwrap().id;
let uuid = horizon_client::save::uuid_text(game.persist.uuid_of(id).expect("a UUID"));
let name = game.target_name(Target::Mob(id)).component();
assert_eq!(name["translate"], "entity.minecraft.pig");
assert_eq!(name["insertion"], uuid.as_str());
let hover = &name["hover_event"];
assert_eq!(hover["action"], "show_entity");
assert_eq!(hover["id"], "minecraft:pig");
assert_eq!(hover["uuid"], uuid.as_str());
assert_eq!(hover["name"]["translate"], "entity.minecraft.pig");
// On a team the name is the team's formatting of it, the hover on the whole.
assert!(game.run_command("team add reds"));
assert!(game.run_command("team modify reds color red"));
assert!(game.run_command("team join reds @e[type=minecraft:pig]"));
let name = game.target_name(Target::Mob(id)).component();
assert_eq!(name["color"], "red");
assert_eq!(name["insertion"], uuid.as_str());
assert_eq!(name["hover_event"]["uuid"], uuid.as_str());
}
+14 -236
View File
@@ -450,24 +450,6 @@ impl Game<'_> {
used = true;
}
if m.is_baby() && age_up > 0 && !m.extra.age_locked {
// AbstractHorse.handleEating (Camel's too): the HAPPY_VILLAGER's
// `getRandomX(1.0)`, `getRandomY() + 0.5` and `getRandomZ(1.0)`
// each draw a double from the mount's own random before it ages
// up. A server's `Level.addParticle` shows nothing: the client
// draws its own sparkle (`remote_feed_particles`).
let (feet, width, height) = (
[m.position.x, m.position.y, m.position.z],
f64::from(m.width()),
f64::from(m.height()),
);
horizon_client::particles::around_self(
&mut m.random,
feet,
width,
height,
horizon_client::particles::Burst::HORSE_EATING,
|_, _| {},
);
m.extra.age = (m.extra.age + age_up * 20).min(0);
used = true;
}
@@ -478,29 +460,22 @@ impl Game<'_> {
if !used {
return false;
}
// eating(): the mouth opens and, unless the mount is silent, it eats at
// its feet, its own random drawing `1.0F + (nextFloat() - nextFloat())
// * 0.2F` for the pitch.
// eating(): the mouth opens and the horse eats.
m.anim.v[2] = 1.0;
let at = [m.position.x, m.position.y, m.position.z];
let at = m.center();
let id = m.id;
if !m.silent {
let sound = match m.species() {
Species::Camel => "minecraft:entity.camel.eat",
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
Species::Donkey => "minecraft:entity.donkey.eat",
Species::Mule => "minecraft:entity.mule.eat",
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
// ZombieHorse.getEatingSound.
Species::ZombieHorse => "minecraft:entity.zombie_horse.eat",
_ => "minecraft:entity.horse.eat",
};
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
self.audio.play(sound, Some(at), 1.0, pitch);
}
// gameEvent(GameEvent.EAT): at the mount, from the mount.
let ctx = self.who_context(Some(Who::Mob(id)), None);
self.post_game_event("minecraft:eat", at, ctx);
let sound = match m.species() {
Species::Camel => "minecraft:entity.camel.eat",
Species::CamelHusk => "minecraft:entity.camel_husk.eat",
Species::Donkey => "minecraft:entity.donkey.eat",
Species::Mule => "minecraft:entity.mule.eat",
Species::Horse if m.is_baby() => "minecraft:entity.baby_horse.eat",
// ZombieHorse.getEatingSound.
Species::ZombieHorse => "minecraft:entity.zombie_horse.eat",
_ => "minecraft:entity.horse.eat",
};
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio.play(sound, Some(at), 1.0, pitch);
// Animal.setInLove: entity event 18.
if loved {
self.love_cause.insert(id, self.lan_acting);
@@ -1603,203 +1578,6 @@ mod tests {
assert_eq!(pose, Some(Pose::Swimming));
}
/// A server-side game with nothing in it but an empty chunk, or None when
/// the prepared resources are missing (CI has no vanilla data).
fn server_game() -> Option<Game<'static>> {
use crate::game::{Audio, Options, Progress, Settings, load_resources};
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("movement-properties.nbt").exists() {
return None;
}
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.ok()?;
let mut options = Options::parse(std::iter::empty()).ok()??;
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui?,
options,
settings,
Audio::silent(&root),
None,
)
.ok()?;
game.creative = false;
game.options.dedicated = Some(Vec::new());
game.world.chunks.insert(
(0, 0),
std::sync::Arc::new(horizon_client::chunk::Chunk::empty(0, 0)),
);
game.streaming.forced.insert((0, 0));
Some(game)
}
/// What one feeding made the level show.
#[derive(Debug)]
struct Shown {
sounds: Vec<(String, [f64; 3], f32, f32)>,
events: Vec<(&'static str, [f64; 3], Option<Who>)>,
}
/// Feeds `item` to mob 0 and collects the sounds and the `minecraft:eat`
/// game events it made.
fn feed(game: &mut Game<'_>, item: &str) -> (bool, Shown) {
game.inventory[game.selected] = Some(ItemStack::new(item, 2));
game.audio.capture = Some(Vec::new());
crate::game::sculk::TEST_POSTED_EVENTS
.with(|events| *events.borrow_mut() = Some(Vec::new()));
let used = game.interact_mount(0, item);
let sounds = game
.audio
.capture
.take()
.unwrap()
.into_iter()
.map(|s| (s.event, s.position, s.volume, s.pitch))
.collect();
let events = crate::game::sculk::TEST_POSTED_EVENTS.with(|events| {
events
.borrow_mut()
.take()
.unwrap_or_default()
.into_iter()
.filter(|(event, _, _)| *event == "minecraft:eat")
.collect()
});
(used, Shown { sounds, events })
}
/// A whole, tamed mount of `kind` standing at `at` with `seed` for its own
/// random, alone in `game`.
fn mount(game: &mut Game<'_>, kind: &str, id: u32, at: [f64; 3], seed: i64) {
let kind = game.res.mob_types().get(kind).unwrap().clone();
let mut m = horizon_client::mobs::Mob::new(id, kind, Vec3::new(at[0], at[1], at[2]), 0.0);
m.extra.tame = true;
m.health = m.attrs.max_health;
m.random = horizon_core::random::LegacyRandom::new(seed);
game.mobs.clear();
game.mobs.push(m);
}
/// `AbstractHorse.handleEating`'s growth particle position draws:
/// `getRandomX(1.0)`, `getRandomY()` and `getRandomZ(1.0)` take a double each.
fn sparkle_draws(r: &mut horizon_core::random::LegacyRandom) {
for _ in 0..3 {
r.next_double();
}
}
/// `AbstractHorse.handleEating`: a mouthful that ages a baby up draws the
/// happy villager's position from the mount's own random (`getRandomX`,
/// `getRandomY` and `getRandomZ` take a double each) before it grows; a
/// mount that is not silent then eats with `1.0F + (nextFloat() -
/// nextFloat()) * 0.2F` of its own random at its feet, and every used
/// mouthful is a `minecraft:eat` game event. A silent mount eats
/// noiselessly, drawing no pitch, and a locked baby grows not at all.
#[test]
fn feeding_a_mount_draws_from_its_own_random_and_posts_eat() {
let Some(mut game) = server_game() else {
return;
};
// A silent baby horse fed wheat: the particle's draws, the growth, the
// event, and no eating sound (so no pitch draws).
mount(&mut game, "minecraft:horse", 1, [1.5, 64.0, -2.5], 7);
game.mobs[0].extra.age = -24000;
game.mobs[0].silent = true;
let level_random = game.world.random.clone().next_long();
let (used, shown) = feed(&mut game, "minecraft:wheat");
assert!(used, "wheat is horse food");
let mut r = horizon_core::random::LegacyRandom::new(7);
sparkle_draws(&mut r);
let at = [1.5, 64.0, -2.5];
assert!(shown.sounds.is_empty(), "Silent: {shown:?}");
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(1)))]);
assert_eq!(game.mobs[0].extra.age, -23600, "wheat ages a horse by 20 s");
assert_eq!(
game.mobs[0].random.clone().next_double(),
r.next_double(),
"three doubles for the particle, none for a silent pitch"
);
assert_eq!(
game.world.random.clone().next_long(),
level_random,
"the level's own random drew nothing"
);
// A hurt adult draws no particle and eats at its feet, its pitch from
// its own random, after healing.
mount(&mut game, "minecraft:horse", 2, [0.5, 64.0, 0.5], 11);
game.mobs[0].health = game.mobs[0].attrs.max_health - 5.0;
let max = game.mobs[0].attrs.max_health;
let (used, shown) = feed(&mut game, "minecraft:wheat");
assert!(used);
let mut r = horizon_core::random::LegacyRandom::new(11);
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
let at = [0.5, 64.0, 0.5];
assert_eq!(
shown.sounds,
vec![("minecraft:entity.horse.eat".to_string(), at, 1.0, pitch)],
"{shown:?}"
);
assert_eq!(game.mobs[0].health, max - 3.0, "wheat heals 2");
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(2)))]);
// An age-locked baby that is tamed and whole eats nothing at all.
mount(&mut game, "minecraft:horse", 3, [0.5, 64.0, 2.5], 13);
game.mobs[0].extra.age = -24000;
game.mobs[0].extra.age_locked = true;
let (used, shown) = feed(&mut game, "minecraft:wheat");
assert!(!used, "a locked baby leaves the wheat alone: {shown:?}");
assert!(
shown.sounds.is_empty() && shown.events.is_empty(),
"{shown:?}"
);
assert_eq!(game.mobs[0].extra.age, -24000);
assert_eq!(
game.mobs[0].random.clone().next_double(),
horizon_core::random::LegacyRandom::new(13).next_double(),
"nothing drawn"
);
// A baby camel fed cactus: `Camel.handleEating` draws the same particle,
// then eats and posts EAT, the three doubles before the two floats.
mount(&mut game, "minecraft:camel", 4, [0.5, 64.0, 4.5], 21);
game.mobs[0].extra.age = -24000;
let (used, shown) = feed(&mut game, "minecraft:cactus");
assert!(used, "cactus is camel food");
let mut r = horizon_core::random::LegacyRandom::new(21);
sparkle_draws(&mut r);
let pitch = (r.next_float() - r.next_float()) * 0.2 + 1.0;
let at = [0.5, 64.0, 4.5];
assert_eq!(
shown.sounds,
vec![("minecraft:entity.camel.eat".to_string(), at, 1.0, pitch)],
"{shown:?}"
);
assert_eq!(shown.events, vec![("minecraft:eat", at, Some(Who::Mob(4)))]);
assert_eq!(game.mobs[0].extra.age, -23800, "camel cactus ages 10 s");
}
#[test]
fn the_escape_vector_reaches_the_edge_of_the_square() {
let edge = (0.9 + 0.6 + f64::from(1.0e-5_f32)) / 2.0;
@@ -5,7 +5,7 @@
//! a NeoForge mod calls its own `SimpleCriterionTrigger.trigger`.
use super::{Game, advancements::item_view, commands::Target};
use horizon_client::{
advancements::{self as adv, EntityView, Tree},
advancements::{self as adv, EntityView},
items::ItemStack,
};
use serde_json::Value;
@@ -129,25 +129,6 @@ fn passes(
&& adv::conditions(&conditions["player"], &player, world)
}
/// Whether a per-tick trigger needs the player's view. Until the advancements
/// are loaded the tree is not read yet, so the view is built as before. After
/// that, only the lookup `trigger` makes (`by_trigger`) can use it.
fn view_needed(loaded: bool, tree: &Tree, trigger: &str) -> bool {
!loaded || tree.by_trigger.contains_key(trigger)
}
impl Game<'_> {
/// [`Self::player_entity_for`] when [`view_needed`] says so, and an empty
/// view otherwise: `trigger` then finds no listener and never reads it.
pub(super) fn player_entity_if_listened(&mut self, trigger: &str) -> EntityView {
if view_needed(self.adv.loaded, &self.adv.tree, trigger) {
self.player_entity_for(trigger)
} else {
EntityView::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -244,16 +225,4 @@ mod tests {
assert!(!mod_trigger("inventory_changed"));
assert!(!mod_trigger(":pipe_connected"));
}
#[test]
fn view_is_needed_only_for_a_listened_trigger_once_loaded() {
let mut tree = Tree::default();
// Before loading the tree is empty but not read yet: built as before.
assert!(view_needed(false, &tree, "minecraft:tick"));
assert!(!view_needed(true, &tree, "minecraft:tick"));
tree.by_trigger
.insert("minecraft:tick".into(), vec![(0, 0)]);
assert!(view_needed(true, &tree, "minecraft:tick"));
assert!(!view_needed(true, &tree, "minecraft:location"));
}
}
@@ -661,7 +661,7 @@ impl Game<'_> {
let mut waiting: Vec<u32> = Vec::new();
let tracked = &self.portals.entities;
for_each_entity!(entity_lists_of!(self), entity => {
let can_sweep = entity.sweeps_portals();
let can_sweep = entity.portal_size().is_some();
let has_portal_state = tracked.contains_key(&entity.id());
// Entity.processPortalCooldown still runs for entities this
// portal pass cannot send, such as an ender pearl.
@@ -880,21 +880,15 @@ impl Game<'_> {
}
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
// No loaded section has a portal state in its palette (nearly always): no
// sweep below can meet a portal block, so the walk would find nothing.
if !self.level_may_hold_portal(&mut portal_sections) {
self.portals.sections = portal_sections;
return;
}
// Only the entities that tick (in entity-ticking chunks) move, so only
// they can have swept into a portal.
let entity_ticking = self.entity_ticking_area();
// The first portal block each entity's box swept through since the
// start of its tick (a stuck arrow that did not move enters none).
for_each_entity!(entity_lists_of!(self), entity => {
if !entity.sweeps_portals() {
let Some((width, height)) = entity.portal_size() else {
continue;
}
};
let (id, to) = (entity.id(), entity.position());
if !entity_ticking(to.x.floor() as i32, to.z.floor() as i32) {
continue;
@@ -912,9 +906,6 @@ impl Game<'_> {
let from = entity
.portal_from()
.unwrap_or_else(|| self.portals.before.get(&id).copied().unwrap_or(to));
let Some((width, height)) = entity.portal_size() else {
continue;
};
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
}
@@ -1003,33 +994,20 @@ impl Game<'_> {
return true;
}
let y_mask = SectionMask::below(y1 as usize + 1).from(y0 as usize);
let behaviours = self.res.behaviours();
let chunks = &self.world.chunks;
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
chunks.get(&(cx, cz)).is_some_and(|chunk| {
let portals = sections.get((cx, cz), chunk, |v| self.is_portal_state(v));
let portals = sections.get((cx, cz), chunk, |v| {
let state = horizon_world::registry::BlockStateId(v);
behaviours.portal_axis(state).is_some() || self.is_end_portal(state)
});
!(portals & y_mask).is_empty()
})
})
})
}
/// [`Self::may_hold_portal`] for the whole level: whether any loaded section has
/// a nether or End portal state in its palette.
fn level_may_hold_portal(
&self,
sections: &mut horizon_client::portal_sections::PortalSections,
) -> bool {
self.world.chunks.iter().any(|(&key, chunk)| {
!sections
.get(key, chunk, |v| self.is_portal_state(v))
.is_empty()
})
}
/// A nether or End portal state, by its id in a palette.
fn is_portal_state(&self, v: u32) -> bool {
let state = horizon_world::registry::BlockStateId(v);
self.res.behaviours().portal_axis(state).is_some() || self.is_end_portal(state)
}
/// `Entity.teleportCrossDimension` away from the loaded dimension: the
/// entity is saved and removed, and waits for its dimension to load.
fn send_through_portal(&mut self, id: u32, entry: BlockPos) {
+1 -44
View File
@@ -126,8 +126,6 @@ impl Game<'_> {
self.options_from_title = from_title;
let minecart_improvements = self.minecart_improvements();
let mods_live = self.mods_live_now();
// From the title after leaving a server, the row is back before the next world ticks.
self.hold_fullbright();
let ui = &mut self.ui.options;
ui.open_at(!from_title && self.title.is_none(), page);
ui.mods_live = mods_live;
@@ -562,10 +560,10 @@ impl Game<'_> {
{
self.settings.horizon.save(dir);
}
self.hold_fullbright();
let s = &self.settings;
if let Some(r) = self.renderer.as_mut() {
r.gamma = s.gamma;
r.fullbright = s.extras.fullbright;
r.improved_transparency = s.improved_transparency;
r.set_present_mode(s.vsync, s.horizon.present_mode);
r.animated_textures = s.horizon.animated_textures;
@@ -742,30 +740,6 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// Fullbright as this window may draw it (`fullbright_held`): the renderer's flag and the
/// graphics screen's row. Each tick (`tick_frame_visuals`) and when settings apply, so
/// joining or leaving a server needs no call of its own.
pub(super) fn hold_fullbright(&mut self) {
let held = fullbright_held(
self.remote.as_ref().map(|r| r.save_folder.is_some()),
self.studio_replay.is_some(),
);
self.settings.fullbright_held = held;
if let Some(r) = self.renderer.as_mut() {
r.fullbright = self.settings.extras.fullbright && !held;
}
}
}
/// Fullbright is held off on another's server, where it would light what the server shows
/// dark (#3823): a connection with no save of this window's own (`Remote::save_folder`), as a
/// multiplayer server or a LAN guest has. Single player, a LAN host, a splitscreen seat in
/// P1's world and Replay Studio keep it. `connection`: none, or whether it plays a save.
fn fullbright_held(connection: Option<bool>, studio: bool) -> bool {
connection == Some(false) && !studio
}
/// The named device can be a connected pad that has not driven the game yet.
fn detected_controller_style(
device: Option<&horizon_client::controller_ui::Device>,
@@ -801,23 +775,6 @@ fn windowed_again(w: &winit::window::Window) {
}
}
#[cfg(test)]
mod fullbright_tests {
use super::fullbright_held;
#[test]
fn fullbright_is_held_only_on_another_servers_world() {
// The title screen, with no connection.
assert!(!fullbright_held(None, false));
// Single player and a LAN host play their own save; so does a splitscreen seat in P1's.
assert!(!fullbright_held(Some(true), false));
// A multiplayer server, or another player's world opened to LAN.
assert!(fullbright_held(Some(false), false));
// Replay Studio plays a recording, not a server.
assert!(!fullbright_held(Some(false), true));
}
}
#[cfg(test)]
mod controller_context_tests {
use super::detected_controller_style;
@@ -20,7 +20,6 @@ use std::{
type ChunkKey = (i32, i32);
type SectionKey = (i32, i32, i32);
const MAX_SNAPSHOT_CHUNKS: i64 = 4_096;
const MAX_PARTICLE_ANSWERS: usize = 4_096;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
struct CullKey {
@@ -176,48 +175,25 @@ impl ParticleAsyncCuller {
}
fn begin_frame(&mut self, camera: glam::DVec3, world: &World) {
self.camera = camera.to_array();
self.stamp = SnapshotKey {
world_revision: world.revision,
chunks_revision: world.chunks.membership_revision(),
bottom_y: world.bottom_y(),
};
let camera_bits = self.camera.map(f64::to_bits);
while let Ok(mut results) = self.results_rx.try_recv() {
self.pending = false;
let scratch = std::mem::take(&mut results.scratch_queries);
if scratch.capacity() > self.queries.get_mut().capacity() {
*self.queries.get_mut() = scratch;
}
// A job only answers the clusters that missed, so merge instead of replacing.
if results.stamp != self.stamp {
continue;
}
results.answers.retain(|(key, _)| key.camera == camera_bits);
if let Some(old) = self
.results
.as_mut()
.filter(|old| old.stamp == results.stamp)
{
old.answers.retain(|(key, _)| key.camera == camera_bits);
// Newest first, so dedup keeps the newest answer of a key.
let mut merged = std::mem::take(&mut results.answers);
merged.append(&mut old.answers);
merged.sort_by_key(|(key, _)| *key);
merged.dedup_by_key(|(key, _)| *key);
merged.truncate(MAX_PARTICLE_ANSWERS);
old.answers = merged;
} else {
self.results = Some(results);
}
self.results = Some(results);
}
if self.results.as_ref().is_some_and(|results| {
results.stamp != self.stamp
|| results
.answers
.iter()
.any(|(key, _)| key.camera != camera_bits)
}) {
self.camera = camera.to_array();
self.stamp = SnapshotKey {
world_revision: world.revision,
chunks_revision: world.chunks.membership_revision(),
bottom_y: world.bottom_y(),
};
if self
.results
.as_ref()
.is_some_and(|results| results.stamp != self.stamp)
{
self.results = None;
}
self.queries.get_mut().clear();
@@ -805,43 +781,4 @@ mod tests {
assert!(results.answers[0].1.is_none());
drop(live);
}
#[test]
fn answers_of_an_earlier_job_survive_a_later_job_for_other_clusters() {
use std::time::{Duration, Instant};
let world = World::new(horizon_world::registry::BlockStateId(0), 0);
let mut culler = ParticleAsyncCuller::new().unwrap();
culler.begin_frame(glam::DVec3::ZERO, &world);
let (snapshot, _live) = wall(None);
let key = |x: f64| CullKey {
camera: [0.0; 3].map(f64::to_bits),
min: [x, -0.55, -0.55].map(f64::to_bits),
max: [x + 2.1, 0.55, 0.55].map(f64::to_bits),
};
let (a, b) = (key(7.95), key(11.95));
for k in [a, b] {
culler
.jobs
.send(CullJob {
stamp: culler.stamp,
snapshot: Arc::clone(&snapshot),
queries: vec![k],
})
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
loop {
culler.begin_frame(glam::DVec3::ZERO, &world);
if culler.cached(&k).is_some() {
break;
}
assert!(Instant::now() < deadline, "worker did not answer");
std::thread::sleep(Duration::from_millis(5));
}
}
assert!(
culler.cached(&a).is_some(),
"answer of the first job was dropped"
);
assert!(culler.cached(&b).is_some());
}
}
@@ -62,11 +62,6 @@ impl Game<'_> {
self.capture(true);
}
Screen::Paused | Screen::Inventory => {
// RecipeBookComponent.keyPressed: a narrow window's open
// book takes Escape to close itself, not the screen.
if self.screen == Screen::Inventory && self.recipe_book_escape() {
return Ok(());
}
self.close_container();
self.pause_focus = None;
self.screen = Screen::Playing;
@@ -49,14 +49,7 @@ impl Game<'_> {
&& !self.is_published()
&& !self.has_split_players()
&& self.title.is_none()
&& top_screen_pauses(
// A dialog (and its warning or waiting screen) is the
// screen Minecraft set, over whatever was open.
self.dialog_open().then(|| self.dialogs.pauses_world()),
self.book.as_ref().map(|book| book.pauses_world()),
self.screen.is_pause_screen(),
self.win_screen_pauses(),
)
&& (self.screen.is_pause_screen() || self.book.is_some() || self.win_screen_pauses())
}
/// A screen open over gameplay while `screen` stays `Playing`: a book or
/// lectern, a sign, a server dialog, the code of conduct, a link or
@@ -369,17 +362,9 @@ impl Game<'_> {
}
}
/// Which of the things over gameplay decides `Minecraft.pause`: a dialog is
/// its own `DialogScreen` (Minecraft set it over whatever was open) and
/// comes first, then a book, else `Gui.screen`; the win screen's poem rides
/// with its screen. `None` means that kind of screen is not open.
fn top_screen_pauses(dialog: Option<bool>, book: Option<bool>, screen: bool, win: bool) -> bool {
dialog.or(book).unwrap_or(screen || win)
}
#[cfg(test)]
mod tests {
use super::{studio_suppresses_focus_pause, top_screen_pauses};
use super::studio_suppresses_focus_pause;
#[test]
fn studio_replay_and_freecam_never_pause_on_focus_loss() {
@@ -387,27 +372,4 @@ mod tests {
assert!(studio_suppresses_focus_pause(false, true));
assert!(!studio_suppresses_focus_pause(false, false));
}
/// `Minecraft.pause` asks the screen Minecraft set, and each of a dialog,
/// a book and the win screen is a screen of its own that replaces what
/// was open: the dialog's `pause`, the book's `BookViewScreen`/
/// `LecternScreen`, and `WinScreen`'s `!poem` decide, not the screen
/// underneath.
#[test]
fn the_screen_on_top_decides_whether_the_world_pauses() {
// Nothing replaces the screen: its own isPauseScreen answer, with
// the win screen's poem rule when it is the win screen.
assert!(top_screen_pauses(None, None, true, false));
assert!(top_screen_pauses(None, None, false, true));
assert!(!top_screen_pauses(None, None, false, false));
// A dialog over a pause screen: its own pause answers.
assert!(!top_screen_pauses(Some(false), None, true, false));
assert!(top_screen_pauses(Some(true), None, false, false));
// A lectern's book over a pause screen does not pause; a book in
// hand does.
assert!(!top_screen_pauses(None, Some(false), true, false));
assert!(top_screen_pauses(None, Some(true), false, false));
// A dialog opens over the book.
assert!(!top_screen_pauses(Some(false), Some(true), false, false));
}
}
+1 -6
View File
@@ -1211,12 +1211,7 @@ impl Game<'_> {
twinkle: false,
}],
));
let rocket = self.new_rocket(
Vec3::new(at[0], at[1], at[2]),
&stack,
false,
&mut random_source::entity(),
);
let rocket = self.new_rocket(Vec3::new(at[0], at[1], at[2]), &stack, false);
self.rockets.push(rocket);
}
/// `BehaviorUtils.throwItem`: a hero's gift tossed toward the player.
@@ -31,13 +31,6 @@ const SWAP_TICKS: f32 = 30.0;
/// Widest GUI at which the book sits next to the menu (`widthTooNarrow`).
const NARROW: f32 = 379.0;
/// `RecipeBookComponent.isOffsetNextToMainGUI`: false in a window narrower than
/// `NARROW`, where the open book covers the menu. Escape closes only the book
/// there (`keyPressed`), not the container.
pub(super) fn escape_closes_book(width: f32, open: bool) -> bool {
open && width < NARROW
}
/// A ghost recipe slot: the items it cycles through.
struct Ghost {
slot: usize,
@@ -1122,22 +1115,6 @@ impl Game<'_> {
self.send_book_settings(book_type);
self.recipe_ui.overlay = None;
}
/// `RecipeBookComponent.keyPressed`: Escape closes the book, not the
/// screen, while it covers a narrow menu; a wide window's Escape reaches
/// the container.
pub(super) fn recipe_book_escape(&mut self) -> bool {
let Some(book_type) = self.active_book() else {
return false;
};
let Some(batch) = self.batch_for_size() else {
return false;
};
if !escape_closes_book(batch.width, self.book_open(book_type)) {
return false;
}
self.close_narrow_book(book_type, &batch);
true
}
/// `RecipeBookComponent.tryPlaceRecipe`: false when the click is refused.
fn try_place(&mut self, entry_index: usize, use_max: bool) -> bool {
let Some(book_type) = self.active_book() else {
@@ -1922,16 +1899,3 @@ fn sub_rect(r: Rect, [x, y, w, h]: [f32; 4]) -> Rect {
h: h as u32,
}
}
#[cfg(test)]
mod tests {
/// `RecipeBookComponent.keyPressed`: Escape closes a narrow window's open
/// book (`!isOffsetNextToMainGUI`), not the container; a wide window's
/// book leaves Escape to the container.
#[test]
fn escape_closes_only_a_narrow_books_menu() {
assert!(super::escape_closes_book(super::NARROW - 1.0, true));
assert!(!super::escape_closes_book(super::NARROW, true));
assert!(!super::escape_closes_book(super::NARROW - 1.0, false));
}
}
+1 -4
View File
@@ -11199,7 +11199,7 @@ pub(super) fn item_has_use(info: &horizon_client::items::ItemInfo) -> bool {
let id = info.id.trim_start_matches("minecraft:");
id.ends_with("_spear")
|| info.consumable.is_some()
|| (info.equip_slot.is_some() && info.swappable)
|| info.equip_slot.is_some()
|| info.blocks_attacks.is_some()
|| horizon_client::items::bucket_contents(&info.id).is_some()
|| id.ends_with("_boat")
@@ -13479,9 +13479,6 @@ mod tests {
let rod = items.get(rod).unwrap();
assert!(!super::use_ends_click(rod, gliding), "{}", rod.id);
}
// A carved pumpkin has an equip_slot (head) but is not swappable, so it has no Item.use.
let pumpkin = items.get("minecraft:carved_pumpkin").unwrap();
assert!(!super::use_ends_click(pumpkin, gliding));
}
}
@@ -12,7 +12,6 @@ pub const HELP: &[&str] = &[
"particle NAME X Y Z [DX DY DZ] [COUNT] spawn particles (vanilla /particle spread)",
"signedit X Y Z [front|back] open the sign editor for the sign at X Y Z",
"section-draws [flat|indirect] how terrain sections are drawn (A/B checks); replies the mode",
"section-layout compact|wide solid quads' layout in the arena, all meshed again (A/B checks); replies compact",
"section-cull [on|off] occlusion culling of solid sections (A/B checks); replies the mode",
"section-sides [on|off] leave out solid quads facing away from the eye (A/B checks); replies the mode",
"gpu-culling [on|off|auto] GPU Culling as its setting sets it, not saved (A/B checks); replies the mode",
@@ -426,7 +426,6 @@ impl Game<'_> {
/// flicker whenever the game is not paused, frozen or not, while `TextureManager.tick`
/// needs the level running normally. `HORIZON_STILL` keeps both with the world.
pub(super) fn tick_frame_visuals(&mut self, running: bool) {
self.hold_fullbright();
if running || !self.clock.still() {
self.fog.tick_flicker();
}
+11 -16
View File
@@ -583,25 +583,20 @@ impl Game<'_> {
if include_world {
let root = world_root.as_ref().expect("a local world's save folder");
if let Some(server) = self.integrated.as_ref() {
// The server thread saves, then waits there for the copy: pausing it instead
// runs its pause save (which renames files into place) during the copy.
let (saved_tx, saved) = mpsc::channel();
let (copied, copied_rx) = mpsc::channel::<()>();
// A server that has stopped drops the task, and `saved_tx` with it.
server.post(move |game| {
let _ = saved_tx.send(game.save_game().map_err(|e| e.to_string()));
let _ = copied_rx.recv();
});
match saved.recv() {
Ok(Ok(())) => {}
Ok(Err(e)) => {
server.set_paused(true);
let saved = server.call(|game| game.save_game().map_err(|e| e.to_string()));
let copied = copy_tree(root, &dir.join("world"));
server.set_paused(self.pause);
match saved {
Some(Ok(())) => {}
Some(Err(e)) => {
return Err(format!("saving the world for the recording: {e}").into());
}
Err(_) => return Err("the integrated server has stopped".into()),
None => {
log::warn!("Horizon: the integrated server did not save for the recording")
}
}
let result = copy_tree(root, &dir.join("world"));
drop(copied);
result.map_err(|e| format!("copying the world: {e}"))?;
copied.map_err(|e| format!("copying the world: {e}"))?;
} else {
self.save_game_with(true)?;
copy_tree(root, &dir.join("world"))
+2 -36
View File
@@ -241,7 +241,7 @@ impl Game<'_> {
}
fn holder_for(&mut self, t: Target) -> Option<Holder> {
let name = self.holder_name(t)?;
let shown = feedback_name(self.target_name(t), &name);
let shown = self.target_name(t);
Some(Holder {
display: Some(literal(&shown.plain())),
text: shown,
@@ -957,20 +957,6 @@ pub(super) fn local_tab_players(me: TabPlayer, guests: &[Guest]) -> Vec<TabPlaye
players
}
/// `ScoreHolder.getFeedbackDisplayName`: the holder's display name with its
/// hover replaced by `HoverEvent.ShowText` of its scoreboard name (a player's
/// name, an entity's UUID string); the insertion stays.
fn feedback_name(display: Text, scoreboard_name: &str) -> Text {
let mut component = display.component();
if let serde_json::Value::Object(o) = &mut component {
o.insert(
"hover_event".into(),
serde_json::json!({"action": "show_text", "value": scoreboard_name}),
);
}
display.with_component(component)
}
/// The commands' view of the game.
struct GameEnv<'a, 'b> {
g: &'a mut Game<'b>,
@@ -1011,7 +997,7 @@ impl Env for GameEnv<'_, '_> {
Target::Mob(id) | Target::Item(id) => uuid_text(self.g.uuid_for(id)),
_ => return None,
};
let text = feedback_name(self.g.target_name(entity), &name);
let text = self.g.target_name(entity);
Some((
Holder {
display: Some(literal(&text.plain())),
@@ -1121,26 +1107,6 @@ mod tab_list_tests {
#[cfg(test)]
mod team_tests {
use super::*;
/// #14279: `ScoreHolder.getFeedbackDisplayName` keeps the display name and
/// its insertion, and shows the scoreboard name (an entity's UUID) on hover.
#[test]
fn a_feedback_name_shows_the_scoreboard_name_on_hover() {
let uuid = "00000001-0000-0002-0000-000300000004";
let display = Text::translate("entity.minecraft.pig", &[]).with_component_fields(vec![
(
"hover_event".into(),
serde_json::json!({"action": "show_entity", "id": "minecraft:pig", "uuid": uuid}),
),
("insertion".into(), uuid.into()),
]);
let c = feedback_name(display, uuid).component();
assert_eq!(c["translate"], "entity.minecraft.pig");
assert_eq!(c["insertion"], uuid);
assert_eq!(
c["hover_event"],
serde_json::json!({"action": "show_text", "value": uuid})
);
}
const WOLF: [i32; 4] = [1, 2, 3, 4];
const CAT: [i32; 4] = [5, 6, 7, 8];
+2 -16
View File
@@ -786,19 +786,6 @@ impl Game<'_> {
}
if self.mobs[index].species() == Species::Allay && self.mobs[index].alive() {
self.allay_listen(index, event, pos, ctx);
// Allay.JukeboxListener, its own listener beside the vibrations'.
let playing = match event {
"minecraft:jukebox_play" => Some(true),
"minecraft:jukebox_stop_play" => Some(false),
_ => None,
};
let m = &mut self.mobs[index];
if let Some(playing) = playing
&& allay::hears_jukebox(m, pos)
{
let at = pos.map(|v| v.floor() as i32);
allay::set_jukebox_playing(m, at, playing);
}
}
}
/// A game event at a block (`Level.gameEvent(source, event, pos)`).
@@ -2243,9 +2230,8 @@ impl Game<'_> {
== horizon_client::blocks::Fluid::Water
}
/// `Entity.checkSupportingBlock`: the block under this box closest to the
/// entity. Everything but the player works it out when it asks (`Breeze`'s
/// ground dust too).
pub(super) fn supporting_block(&self, position: Vec3, bounds: Aabb) -> Option<BlockPos> {
/// entity. Everything but the player works it out when it asks.
fn supporting_block(&self, position: Vec3, bounds: Aabb) -> Option<BlockPos> {
let (min, max) = (bounds.min_position(), bounds.max_position());
let foot = Aabb::new(
Vec3::new(min.x, min.y - 1.0e-6, min.z),
@@ -109,19 +109,3 @@ fn a_torch_that_stays_connects_the_wire_in_a_line_and_powers_it() {
"minecraft:redstone_wire[east=side,north=none,power=15,south=none,west=side]"
);
}
/// `TestBlock.ownSignal` reads the block state's `mode`, not the block
/// entity's saved copy: a `start` state with a powered block entity answers
/// 15 even when `/data merge` left a different mode in the entity.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_test_block_reads_the_state_mode_for_its_own_signal() {
let mut game = game();
run(&mut game, &["setblock 5 100 2 test_block[mode=start]"]);
assert!(game.run_command("data merge block 5 100 2 {mode:\"fail\",powered:1b}"));
let pos = BlockPos::new(5, 100, 2);
let state = game.world.block(5, 100, 2);
let signal =
game.with_level(|_, level, _| level.block_entity_signal(pos, state, Direction::North));
assert_eq!(signal, 15, "the state says start and the entity is powered");
}
@@ -7,20 +7,9 @@ use horizon_client::{
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play;
use horizon_world::registry::{BlockRegistry, BlockStateId};
type Pos = (i32, i32, i32);
/// `TestBlock.ownSignal`: the block state's mode is the gate, and only then
/// does the block entity's powered flag count.
pub(super) fn own_signal(registry: &BlockRegistry, state: BlockStateId, powered: bool) -> i32 {
let start = registry
.state(state)
.and_then(|s| s.properties.get("mode"))
.is_some_and(|mode| mode == "start");
if start && powered { 15 } else { 0 }
}
impl Game<'_> {
fn test_block_mut(&mut self, pos: Pos) -> Option<&mut TestBlock> {
match self.world.block_entities.get_mut(&pos)? {
@@ -213,11 +213,7 @@ fn dragon_takes_player_thorns_through_its_body() {
Some(("minecraft:thorns", Some(77), Some(77)))
);
assert_eq!(d.hurt_by_player, Some((FightWho::Host, 0)));
// Thorns' own 2 plus the hit's `max(1, damage / 4)`: 2 for the head's 10, 1 for the wing's 5.
assert_eq!(
g.armor[1].as_ref().unwrap().damage,
if wing { 3 } else { 4 }
);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 2);
assert_eq!(
g.audio
.capture
@@ -235,10 +231,10 @@ fn dragon_takes_player_thorns_through_its_body() {
let health = g.player.as_ref().unwrap().health.health();
g.dragon_hurts_player("minecraft:mob_attack", 1.0);
assert_eq!(g.player.as_ref().unwrap().health.health(), health);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 5);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 4);
let before = g.dragon.as_ref().unwrap().health;
g.dragon_hurts_player("minecraft:indirect_magic", 1.0);
assert_eq!(g.dragon.as_ref().unwrap().health, before);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 5);
assert_eq!(g.armor[1].as_ref().unwrap().damage, 4);
});
}
+6 -26
View File
@@ -23,8 +23,7 @@ pub(super) const HELP: &[&str] = &[
];
/// After a click, `ui`, `click` and `set` wait for a frame drawn since, so
/// they never read the screen the click left; before the first frame, for
/// that one, as a game answers its socket before it has drawn anything.
/// they never read the screen the click left.
#[derive(Default)]
pub(super) struct UiWait {
/// The widget generation at the last click, and when it happened.
@@ -34,10 +33,9 @@ pub(super) struct UiWait {
impl UiWait {
fn stale(&self) -> bool {
widgets::generation() == 0
|| self.after.is_some_and(|(generation, at)| {
generation == widgets::generation() && at.elapsed() < Duration::from_secs(1)
})
self.after.is_some_and(|(generation, at)| {
generation == widgets::generation() && at.elapsed() < Duration::from_secs(1)
})
}
/// The commands that can run now.
pub(super) fn ready(&mut self) -> Vec<Request> {
@@ -123,8 +121,7 @@ impl Game<'_> {
})
}
/// `ui`, `click` and `set`, and `screen` and `click-button` held until the first frame;
/// `Handled::No` for other commands.
/// `ui`, `click` and `set`; `Handled::No` for other commands.
pub(super) fn ui_command(
&mut self,
words: &[&str],
@@ -132,10 +129,7 @@ impl Game<'_> {
reply: &Sender<String>,
event_loop: &dyn EventLoopCtl,
) -> Result<Handled> {
// `screen` and `click-button` read the last drawn frame too: they wait for the first.
let first_frame =
matches!(words[0], "screen" | "click-button") && widgets::generation() == 0;
if !matches!(words[0], "ui" | "click" | "set") && !first_frame {
if !matches!(words[0], "ui" | "click" | "set") {
return Ok(Handled::No);
}
if self.ui_wait.stale() {
@@ -224,17 +218,3 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A game answers its socket before it draws: `ui` would read no widgets yet.
#[test]
fn widget_commands_wait_for_the_first_frame() {
let wait = UiWait::default();
assert!(wait.stale());
widgets::end_frame(1);
assert!(!wait.stale());
}
}
@@ -56,7 +56,7 @@ fn datapack_damage_initial_snapshot_uses_the_prepared_worlds_features() {
fs::create_dir_all(pack.join("data/demo/damage_type")).unwrap();
fs::write(
pack.join("pack.mcmeta"),
r#"{"pack":{"description":""},"features":{"enabled":["minecraft:trade_rebalance"]}}"#,
r#"{"features":{"enabled":["minecraft:trade_rebalance"]}}"#,
)
.unwrap();
fs::write(
+164 -36
View File
@@ -21,16 +21,10 @@ use horizon_core::{
#[cfg(test)]
#[path = "fall_flying_tests.rs"]
mod fall_flying_tests;
#[path = "ai_glide.rs"]
mod glide;
use glide::update_fall_flying;
#[cfg(test)]
#[path = "wet_fire_tests.rs"]
mod wet_fire_tests;
mod step;
pub(crate) use step::step_sound;
use crate::powder_snow::particle_velocity as powder_snow_particle_velocity;
/// The inside-block effects of fire and lava (`InsideBlockEffectType`
@@ -713,7 +707,9 @@ pub fn tick(
if m.no_ai {
update_fall_flying(m, w, events);
reset_effect_fall_distance(m);
// Mob.aiStep still runs without AI: undead burn, endermites age.
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
// so undead still burn in daylight and the endermite's life keeps
// counting.
burn_undead(m, w, events);
if m.species() == Species::Endermite {
endermite_lifetime(m);
@@ -762,7 +758,6 @@ pub fn tick(
if m.species() == Species::GlowSquid {
aquatic::glow_squid_dark_tick(m);
}
super::cube::no_ai_tick(m, w, events); // AbstractCubeMob.tick
// Shulker.tick after its living tick: a NoAI shell still keeps a
// face and eases its lid, pushing what the lid opens into.
if m.species() == Species::Shulker {
@@ -1682,6 +1677,61 @@ fn ageable_step(
}
}
/// `LivingEntity.updateFallFlying` on the server, which `aiStep` calls while
/// the glide flag is set, with or without AI: a mob that cannot glide
/// (`canGlide`: on the ground, riding, levitating, or no slot holding a
/// glider whose next damage will not break it, `canGlideUsing`) stops
/// gliding; else on every 20th glide tick one of its gliders, picked with
/// `Util.getRandom` from the mob's own random, takes a point of wear.
/// `checkFallDistanceAccumulation` and the `ELYTRA_GLIDE` game event of
/// every 10th tick are not ported here.
fn update_fall_flying(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
use horizon_world::player_inventory::EquipmentSlot;
if !m.extra.fall_flying {
return;
}
let gliders: Vec<EquipmentSlot> = match w.items {
Some(items) => EquipmentSlot::ALL
.into_iter()
.filter(|&slot| {
super::finalize::equipment_stack(m, slot).is_some_and(|stack| {
crate::item_sync::can_glide_using(&stack, glider_slot_name(slot), items)
})
})
.collect(),
None => Vec::new(),
};
if m.on_ground
|| m.extra.mount.is_some()
|| m.has_effect("minecraft:levitation")
|| gliders.is_empty()
{
m.extra.fall_flying = false;
return;
}
let ticks = m.extra.fall_fly_ticks + 1;
if ticks % 10 == 0 && (ticks / 10) % 2 == 0 {
let pick = m.random.next_int_bound(gliders.len() as i32).unwrap_or(0) as usize;
events.push(MobEvent::GliderWear {
mob: m.id,
slot: gliders[pick],
});
}
}
/// `EquipmentSlot`'s serialized name, which `equippable` names its slot by.
fn glider_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
use horizon_world::player_inventory::EquipmentSlot as S;
match slot {
S::MainHand => "mainhand",
S::OffHand => "offhand",
S::Feet => "feet",
S::Legs => "legs",
S::Chest => "chest",
S::Head => "head",
S::Body => "body",
S::Saddle => "saddle",
}
}
/// `LivingEntity.tick` after `aiStep`: the glide's tick count.
fn count_fall_fly_ticks(m: &mut Mob) {
m.extra.fall_fly_ticks = if m.extra.fall_flying {
@@ -2175,7 +2225,7 @@ fn species_ai_step(
Species::Vex => super::vex::ai_step(m, w),
Species::Ravager => super::ravager::ai_step(m, w, events),
Species::Wither => super::wither::ai_step(m, w, level_random, events),
Species::Allay => super::allay::ai_step(m, w),
Species::Allay => super::allay::ai_step(m),
Species::Bee => super::bee::ai_step(m, w),
Species::PolarBear => super::polar_bear::ai_step(m, w),
Species::Breeze => super::breeze::ai_step(m),
@@ -2258,10 +2308,10 @@ fn check_despawn(m: &mut Mob, w: &MobWorld) -> bool {
let persistent = m.persistent || requires_custom_persistence(m);
// removeWhenFarAway: animals, villagers, golems, patrols and raiders,
// zombie villagers being cured and ridden mobs stay, bar the two felines.
let creature = m.kind.category == "creature";
let feline_goes = feline_removed_when_far(m.species(), m.extra.tame || m.extra.trusting, m.age);
let removable = !(never_removed_when_far(m.species())
|| super::aquatic::named_keeps_it(m)
|| m.kind.category == "creature" && m.kind.flags.animal && !m.extra.jockey && !feline_goes
|| creature && m.kind.flags.animal && !m.extra.jockey && !feline_goes
|| super::raider::keeps_alive(m, d)
|| super::zombie_villager::keeps_alive(m)
|| super::riders::keeps_alive(m));
@@ -3210,6 +3260,109 @@ pub fn movement_sounds(m: &Mob) -> bool {
| Species::Breeze
)
}
/// `playStepSound`: the species' footstep, or the block's step sound at 15%.
pub fn step_sound(
m: &Mob,
w: &MobWorld,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
) -> Option<(&'static str, f32, f32)> {
use Species as S;
let baby = m.is_baby();
let quiet = |s: &'static str| Some((s, 0.15, 1.0));
match m.species() {
// `playStepSound` overridden with an empty body: a fish, an allay, a bee,
// a happy ghast and a nautilus have no footfall of their own, so the
// block's step sound is not taken either.
S::Allay
| S::Bee
| S::Cod
| S::Salmon
| S::TropicalFish
| S::Pufferfish
| S::Tadpole
| S::HappyGhast
| S::Nautilus
| S::ZombieNautilus => None,
S::Armadillo => quiet("entity.armadillo.step"),
S::Chicken | S::Cow | S::Pig | S::Wolf => {
super::finalize::variant_sound(m, "step").and_then(quiet)
}
S::Mooshroom => quiet("entity.cow.step"),
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
S::Frog => quiet("entity.frog.step"),
S::Goat => quiet("entity.goat.step"),
S::Panda => quiet("entity.panda.step"),
S::Parrot => quiet("entity.parrot.step"),
S::PolarBear => quiet("entity.polar_bear.step"),
S::Sheep => quiet("entity.sheep.step"),
S::Sniffer => quiet("entity.sniffer.step"),
S::Turtle if baby => quiet("entity.turtle.shamble_baby"),
S::Turtle => quiet("entity.turtle.shamble"),
S::Creaking => quiet("entity.creaking.step"),
S::Hoglin => quiet("entity.hoglin.step"),
S::Zoglin => quiet("entity.zoglin.step"),
S::Piglin => quiet("entity.piglin.step"),
S::PiglinBrute => quiet("entity.piglin_brute.step"),
S::Ravager => quiet("entity.ravager.step"),
S::Skeleton => quiet("entity.skeleton.step"),
S::Stray => quiet("entity.stray.step"),
S::Bogged => quiet("entity.bogged.step"),
S::Parched => quiet("entity.parched.step"),
S::WitherSkeleton => quiet("entity.wither_skeleton.step"),
S::Spider | S::CaveSpider => quiet("entity.spider.step"),
// Silverfish.playStepSound and Endermite.playStepSound: both ask for
// their own step at 15%, though neither type's movement emission plays
// it (`movement_sounds`).
S::Silverfish => quiet("entity.silverfish.step"),
S::Endermite => quiet("entity.endermite.step"),
S::Zombie | S::ZombifiedPiglin => quiet("entity.zombie.step"),
S::Husk => quiet("entity.husk.step"),
S::Drowned => quiet("entity.drowned.step"),
S::ZombieVillager => quiet("entity.zombie_villager.step"),
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
// powder have their own step.
S::CamelHusk | S::Camel => {
let sand = w.has_tag(
"minecraft:camel_sand_step_sound_blocks",
&w.facts.get(state).name,
);
Some(match (m.species(), sand) {
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
})
}
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
// CopperGolem.playStepSound: its oxidation level's step.
S::CopperGolem => Some((super::copper_golem::sound(m, "step"), 1.0, 1.0)),
S::Strider if m.fluids.in_lava => Some(("entity.strider.step_lava", 1.0, 1.0)),
S::Strider => Some(("entity.strider.step", 1.0, 1.0)),
S::Warden => Some(("entity.warden.step", 10.0, 1.0)),
_ => {
// Entity.playStepSound: the block's sound type.
let _ = pos;
let sounds = &w.facts.get(state).sounds;
let step: &'static str = super::ai::intern_sound(&sounds.step);
Some((step, sounds.volume * 0.15, sounds.pitch))
}
}
}
/// A block sound id as a static string (the block facts intern them).
pub fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
use std::{collections::HashSet, sync::Mutex};
static NAMES: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
let set = names.get_or_insert_with(HashSet::new);
let key = s.strip_prefix("minecraft:").unwrap_or(s);
if let Some(found) = set.get(key) {
return found;
}
let leaked: &'static str = Box::leak(key.to_owned().into_boxed_str());
set.insert(leaked);
leaked
}
/// `getSwimSound`. A skeleton horse's own override counts the steps it takes
/// through the water, so it needs the mob, not just its species.
pub fn swim_sound(m: &mut Mob) -> &'static str {
@@ -3281,31 +3434,6 @@ mod despawn_tests {
assert!(!feline_removed_when_far(s, false, 12000), "{s:?}");
}
}
/// `AbstractFish.removeWhenFarAway` and `Axolotl.removeWhenFarAway`: a fish or
/// an axolotl with a custom name is kept however far it swims, while the name
/// saves no other mob — a monster still goes.
#[test]
fn a_named_fish_or_axolotl_never_despawns() {
crate::mobs::world::with_test_world(&[], |w, types| {
let far = MobWorld {
// 200 blocks from the player: past the 128 a monster loses.
nearest_player: Some(Vec3::new(200.0, 64.0, 0.0)),
..w.clone()
};
let checked = |id: &str, name: Option<&str>| {
let kind = std::sync::Arc::new(types.types[id].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.0, 64.0, 0.0), 0.0);
m.custom_name = name.map(str::to_owned);
despawn_check(&mut m, &far)
};
for fish in ["minecraft:cod", "minecraft:salmon", "minecraft:axolotl"] {
assert!(checked(fish, Some("Bubbles")), "{fish} keeps its name");
assert!(!checked(fish, None), "{fish} without one goes");
}
// A name is not persistence for anything else.
assert!(!checked("minecraft:zombie", Some("Bubbles")));
});
}
}
#[cfg(test)]
-152
View File
@@ -1,152 +0,0 @@
//! `Mob.playStepSound` and its overrides: the species' own footstep, or the block
//! sound at 15%, and the block sound ids the facts carry.
use super::{Mob, MobWorld, Species};
/// `playStepSound`: the species' footstep, or the block's step sound at 15%.
pub fn step_sound(
m: &Mob,
w: &MobWorld,
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
) -> Option<(&'static str, f32, f32)> {
use Species as S;
let baby = m.is_baby();
let quiet = |s: &'static str| Some((s, 0.15, 1.0));
match m.species() {
// `playStepSound` overridden with an empty body: a fish, an allay, a bee,
// a happy ghast and a nautilus have no footfall of their own, so the
// block's step sound is not taken either.
S::Allay
| S::Bee
| S::Cod
| S::Salmon
| S::TropicalFish
| S::Pufferfish
| S::Tadpole
| S::HappyGhast
| S::Nautilus
| S::ZombieNautilus => None,
S::Armadillo => quiet("entity.armadillo.step"),
S::Chicken | S::Cow | S::Pig | S::Wolf => {
crate::mobs::finalize::variant_sound(m, "step").and_then(quiet)
}
S::Mooshroom => quiet("entity.cow.step"),
S::Llama | S::TraderLlama => quiet("entity.llama.step"),
S::Frog => quiet("entity.frog.step"),
S::Goat => quiet("entity.goat.step"),
S::Panda => quiet("entity.panda.step"),
S::Parrot => quiet("entity.parrot.step"),
S::PolarBear => quiet("entity.polar_bear.step"),
S::Sheep => quiet("entity.sheep.step"),
S::Sniffer => quiet("entity.sniffer.step"),
S::Turtle if baby => quiet("entity.turtle.shamble_baby"),
S::Turtle => quiet("entity.turtle.shamble"),
S::Creaking => quiet("entity.creaking.step"),
S::Hoglin => quiet("entity.hoglin.step"),
S::Zoglin => quiet("entity.zoglin.step"),
S::Piglin => quiet("entity.piglin.step"),
S::PiglinBrute => quiet("entity.piglin_brute.step"),
S::Ravager => quiet("entity.ravager.step"),
S::Skeleton => quiet("entity.skeleton.step"),
S::Stray => quiet("entity.stray.step"),
S::Bogged => quiet("entity.bogged.step"),
S::Parched => quiet("entity.parched.step"),
S::WitherSkeleton => quiet("entity.wither_skeleton.step"),
S::Spider | S::CaveSpider => quiet("entity.spider.step"),
// SulfurCube.playStepSound: a cube carrying a block in its body makes no
// footstep at all; an empty one takes the block's sound at the bottom.
S::SulfurCube if crate::mobs::sulfur_cube::has_body(m) => None,
// Silverfish.playStepSound and Endermite.playStepSound: both ask for
// their own step at 15%, though neither type's movement emission plays
// it (`movement_sounds`).
S::Silverfish => quiet("entity.silverfish.step"),
S::Endermite => quiet("entity.endermite.step"),
S::Zombie | S::ZombifiedPiglin => quiet("entity.zombie.step"),
S::Husk => quiet("entity.husk.step"),
S::Drowned => quiet("entity.drowned.step"),
S::ZombieVillager => quiet("entity.zombie_villager.step"),
// Camel.playStepSound and CamelHusk.playStepSound: sand and concrete
// powder have their own step.
S::CamelHusk | S::Camel => {
let sand = w.has_tag(
"minecraft:camel_sand_step_sound_blocks",
&w.facts.get(state).name,
);
Some(match (m.species(), sand) {
(S::Camel, true) => ("entity.camel.step_sand", 1.0, 1.0),
(S::Camel, false) => ("entity.camel.step", 1.0, 1.0),
(_, true) => ("entity.camel_husk.step_sand", 0.4, 1.0),
(_, false) => ("entity.camel_husk.step", 0.4, 1.0),
})
}
S::IronGolem => Some(("entity.iron_golem.step", 1.0, 1.0)),
// CopperGolem.playStepSound: its oxidation level's step.
S::CopperGolem => Some((crate::mobs::copper_golem::sound(m, "step"), 1.0, 1.0)),
S::Strider if m.fluids.in_lava => Some(("entity.strider.step_lava", 1.0, 1.0)),
S::Strider => Some(("entity.strider.step", 1.0, 1.0)),
S::Warden => Some(("entity.warden.step", 10.0, 1.0)),
_ => {
// Entity.playStepSound: the block's sound type.
let _ = pos;
let sounds = &w.facts.get(state).sounds;
let step: &'static str = intern_sound(&sounds.step);
Some((step, sounds.volume * 0.15, sounds.pitch))
}
}
}
/// A block sound id as a static string (the block facts intern them).
pub(crate) fn intern_sound(s: &std::sync::Arc<str>) -> &'static str {
use std::{collections::HashSet, sync::Mutex};
static NAMES: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
let mut names = NAMES.lock().unwrap_or_else(|e| e.into_inner());
let set = names.get_or_insert_with(HashSet::new);
let key = s.strip_prefix("minecraft:").unwrap_or(s);
if let Some(found) = set.get(key) {
return found;
}
let leaked: &'static str = Box::leak(key.to_owned().into_boxed_str());
set.insert(leaked);
leaked
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// `SulfurCube.playStepSound`: a cube carrying a block in its body returns
/// before the block's sound, so it walks silently, while an empty cube takes
/// the block's step at 15% like any other mob.
#[test]
fn a_loaded_sulfur_cube_walks_silently() {
let ran = crate::mobs::world::with_built_test_world(
&[],
|world, registry| {
let stone = registry.default_state("minecraft:stone").unwrap();
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
for x in 0..=4 {
for z in 0..=4 {
world.set(x, 63, z, stone).unwrap();
}
}
},
|w, types| {
let stepped = |body: Option<&str>| {
let kind = Arc::new(types.types["minecraft:sulfur_cube"].clone());
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.body = body.map(Into::into);
let state = w.world.block(0, 63, 0);
step_sound(&m, w, [0, 63, 0], state)
};
assert_eq!(stepped(Some("minecraft:stone")), None, "loaded");
let empty = stepped(None);
assert!(empty.is_some(), "an empty cube takes the block's step");
assert_eq!(empty.unwrap().1, 0.15, "the block's 15%");
},
);
let _ = ran;
}
}
@@ -1,60 +0,0 @@
//! `LivingEntity.updateFallFlying` for a mob: the glide's checks and its gliders' wear.
use super::*;
/// `LivingEntity.updateFallFlying` on the server, which `aiStep` calls while
/// the glide flag is set, with or without AI: a mob that cannot glide
/// (`canGlide`: on the ground, riding, levitating, or no slot holding a
/// glider whose next damage will not break it, `canGlideUsing`) stops
/// gliding; else on every 20th glide tick one of its gliders, picked with
/// `Util.getRandom` from the mob's own random, takes a point of wear.
/// It starts with `checkFallDistanceAccumulation`: a glide that is not
/// diving keeps at most 1 of its fall. The `ELYTRA_GLIDE` game event of
/// every 10th tick is not ported here.
pub(super) fn update_fall_flying(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
use horizon_world::player_inventory::EquipmentSlot;
if !m.extra.fall_flying {
return;
}
crate::mobs::leash::check_fall_distance(m);
let gliders: Vec<EquipmentSlot> = match w.items {
Some(items) => EquipmentSlot::ALL
.into_iter()
.filter(|&slot| {
crate::mobs::finalize::equipment_stack(m, slot).is_some_and(|stack| {
crate::item_sync::can_glide_using(&stack, glider_slot_name(slot), items)
})
})
.collect(),
None => Vec::new(),
};
if m.on_ground
|| m.extra.mount.is_some()
|| m.has_effect("minecraft:levitation")
|| gliders.is_empty()
{
m.extra.fall_flying = false;
return;
}
let ticks = m.extra.fall_fly_ticks + 1;
if ticks % 10 == 0 && (ticks / 10) % 2 == 0 {
let pick = m.random.next_int_bound(gliders.len() as i32).unwrap_or(0) as usize;
events.push(MobEvent::GliderWear {
mob: m.id,
slot: gliders[pick],
});
}
}
/// `EquipmentSlot`'s serialized name, which `equippable` names its slot by.
fn glider_slot_name(slot: horizon_world::player_inventory::EquipmentSlot) -> &'static str {
use horizon_world::player_inventory::EquipmentSlot as S;
match slot {
S::MainHand => "mainhand",
S::OffHand => "offhand",
S::Feet => "feet",
S::Legs => "legs",
S::Chest => "chest",
S::Head => "head",
S::Body => "body",
S::Saddle => "saddle",
}
}
+15 -124
View File
@@ -75,71 +75,11 @@ pub fn considers_item_equal(held: &ItemStack, candidate: &ItemStack) -> bool {
held.item == candidate.item && held.potion() == candidate.potion()
}
/// `GameEvent.JUKEBOX_PLAY.notificationRadius()`: the `JukeboxListener`'s radius.
const JUKEBOX_LISTENER_RADIUS: i64 = 10;
/// `EuclideanGameEventListenerRegistry.getPostableListenerPosition` for the
/// `JukeboxListener`: its source is the allay's eye (`EntityPositionSource`
/// with `getEyeHeight`), and the event is heard when the blocks of the eye and
/// of the event are at most 10 apart (`BlockPos.distSqr`).
pub fn hears_jukebox(m: &Mob, pos: [f64; 3]) -> bool {
let block = |p: [f64; 3]| p.map(|v| i64::from(v.floor() as i32));
let (source, listener) = (block(pos), block([m.position.x, m.eye_y(), m.position.z]));
let d = [0, 1, 2].map(|i| source[i] - listener[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= JUKEBOX_LISTENER_RADIUS * JUKEBOX_LISTENER_RADIUS
}
/// `Allay.setDancing`: only under `isEffectiveAi()` (so `NoAI` never changes
/// it), and a panicking allay does not start.
fn set_dancing(m: &mut Mob, dancing: bool) {
if m.no_ai || dancing && super::goals::should_panic(m) {
return;
}
m.extra.flag = dancing;
}
/// `Allay.setJukeboxPlaying`, from the `JukeboxListener` (`jukebox_play` and
/// `jukebox_stop_play` at `jukebox`): a play starts a dance and keeps that
/// jukebox while the allay is not dancing yet; a stop ends it only from the
/// kept jukebox, or with none kept.
pub fn set_jukebox_playing(m: &mut Mob, jukebox: [i32; 3], playing: bool) {
let Some(kept) = m.extra.allay.as_ref().map(|st| st.jukebox) else {
return;
};
if playing {
if !m.extra.flag {
set_jukebox(m, Some(jukebox));
set_dancing(m, true);
}
} else if kept.is_none() || kept == Some(jukebox) {
set_jukebox(m, None);
set_dancing(m, false);
}
}
fn set_jukebox(m: &mut Mob, jukebox: Option<[i32; 3]>) {
if let Some(st) = m.extra.allay.as_mut() {
st.jukebox = jukebox;
}
}
/// `Allay.aiStep`'s `isDancing() && shouldStopDancing() && tickCount % 20 == 0`:
/// the dance ends when no jukebox is kept, it is not within 10 blocks of the
/// allay (`closerToCenterThan` its centre) or is no jukebox any more.
fn stop_dancing_away(m: &mut Mob, w: &MobWorld) {
if !m.extra.flag || !m.age.is_multiple_of(20) {
return;
}
let kept = m.extra.allay.as_ref().and_then(|st| st.jukebox);
let stays = kept.is_some_and(|[x, y, z]| {
let centre = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.5, f64::from(z) + 0.5);
centre.distance_squared(m.position) < f64::from(JUKEBOX_LISTENER_RADIUS as i32).powi(2)
&& &*w.facts.get(w.world.block(x, y, z)).name == "minecraft:jukebox"
});
if !stays {
set_dancing(m, false);
set_jukebox(m, None);
}
/// `Allay.setDancing` marks an allay dancing only under `isEffectiveAi()`, so
/// `NoAI` never dances; panicking stops it, and 10 blocks is the jukebox reach
/// (`distance_sq` to the nearest playing jukebox's centre, infinite for none).
pub fn dances(no_ai: bool, panicking: bool, distance_sq: f64) -> bool {
!no_ai && !panicking && distance_sq <= 100.0
}
/// `Mob.getPickupReach` (1, 1, 1) around the box: `Mob.aiStep`'s looting loop
@@ -163,8 +103,6 @@ pub struct State {
/// None for a memory that never expires. The memory itself is
/// `Extra::tame` and `Extra::owner`.
pub liked_player_ttl: Option<i64>,
/// `Allay.jukeboxPos`: the jukebox the dance started from. Not saved.
pub jukebox: Option<[i32; 3]>,
}
/// The pocket is independent of the main-hand filter.
@@ -472,10 +410,6 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
if let Some(info) = tick.received {
receive_vibration(m, dimension(w), info);
}
// Allay.tick, after the vibration ticker.
if super::goals::should_panic(m) {
set_dancing(m, false);
}
}
/// `Allay.travel`: `travelFlying(input, getSpeed())` — the airborne speed is
/// the one the move control set this tick, and no gravity applies, so an
@@ -485,12 +419,10 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
m.travel_flying_speeds(w, input, events, 0.02, 0.02, speed);
true
}
/// `Allay.aiStep` / `tick`: healing, the dance's jukebox check, cooldowns, the
/// hold and spin clocks.
pub fn ai_step(m: &mut Mob, w: &MobWorld) {
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
pub fn ai_step(m: &mut Mob) {
tick_liked_player(m);
passive_heal(m);
stop_dancing_away(m, w);
for i in [2, 3] {
if m.extra.ints[i] > 0 {
m.extra.ints[i] -= 1;
@@ -682,55 +614,14 @@ mod allay_parity_tests {
#[test]
fn a_no_ai_allay_does_not_dance_to_a_playing_jukebox() {
let mut mob = test_allay();
mob.no_ai = true;
set_jukebox_playing(&mut mob, [0, 64, 0], true);
assert!(!mob.extra.flag, "NoAI");
mob.no_ai = false;
set_jukebox_playing(&mut mob, [0, 64, 0], true);
assert!(mob.extra.flag, "AI");
// A burning allay panics: it does not start dancing.
let mut burning = test_allay();
burning.fire_ticks = 20;
set_jukebox_playing(&mut burning, [0, 64, 0], true);
assert!(!burning.extra.flag);
}
fn test_allay() -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let kind_mut = std::sync::Arc::make_mut(&mut kind);
kind_mut.species = crate::mobs::Species::Allay;
kind_mut.id = "minecraft:allay".into();
Mob::new(37, kind, Vec3::new(0.5, 64.0, 0.5), 0.0)
}
// Added after the baseline: the kept jukebox and the listener's reach.
#[test]
fn only_the_kept_jukebox_stops_the_dance() {
let mut mob = test_allay();
set_jukebox_playing(&mut mob, [3, 64, 0], true);
// Another jukebox plays: the first one stays the dance's.
set_jukebox_playing(&mut mob, [-4, 64, 0], true);
assert_eq!(mob.extra.allay.as_ref().unwrap().jukebox, Some([3, 64, 0]));
set_jukebox_playing(&mut mob, [-4, 64, 0], false);
assert!(mob.extra.flag, "a stop elsewhere does not end it");
set_jukebox_playing(&mut mob, [3, 64, 0], false);
assert!(!mob.extra.flag);
assert_eq!(mob.extra.allay.as_ref().unwrap().jukebox, None);
// With none kept, any stop ends a dance.
mob.extra.flag = true;
set_jukebox_playing(&mut mob, [9, 64, 9], false);
assert!(!mob.extra.flag);
}
#[test]
fn the_jukebox_listener_hears_ten_blocks_from_the_eye_block() {
let mob = test_allay();
let eye = mob.eye_y().floor();
// Block distance squared from the eye's block: 100 is heard, 101 not.
assert!(hears_jukebox(&mob, [10.5, eye, 0.5]));
assert!(!hears_jukebox(&mob, [10.5, eye + 1.0, 0.5]));
assert!(hears_jukebox(&mob, [6.5, eye + 8.0, 0.5]));
// Beside a playing jukebox and at the edge of its 10-block reach.
for distance_sq in [0.0, 1.5 * 1.5, 99.0, 100.0] {
assert!(!dances(true, false, distance_sq), "NoAI at {distance_sq}");
assert!(dances(false, false, distance_sq), "AI at {distance_sq}");
}
// Out of reach, or panicking: no allay dances.
assert!(!dances(false, false, 100.5));
assert!(!dances(false, true, 1.0));
}
#[test]
-15
View File
@@ -730,21 +730,6 @@ impl Goal for SquidFlee {
}
}
}
/// `AbstractFish.removeWhenFarAway` and `Axolotl.removeWhenFarAway`: a fish or
/// an axolotl with a custom name is never removed for distance, and one let out
/// of a bucket is persistent before it (`isPersistenceRequired`).
pub fn named_keeps_it(m: &Mob) -> bool {
m.custom_name.is_some()
&& matches!(
m.species(),
Species::Cod
| Species::Salmon
| Species::TropicalFish
| Species::Pufferfish
| Species::Tadpole
| Species::Axolotl
)
}
/// `AbstractSchoolingFish`: cod, salmon and tropical fish.
fn schooling(s: Species) -> bool {
matches!(s, Species::Cod | Species::Salmon | Species::TropicalFish)
+11 -33
View File
@@ -12,7 +12,10 @@ use super::{
goal_clone,
},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
};
pub const IDLE: u8 = 0;
pub const ROLLING: u8 = 1;
@@ -83,20 +86,18 @@ struct BabyFollowAdult {
const FOLLOW_MIN: f64 = 5.0;
const FOLLOW_MAX: f64 = 16.0;
const FOLLOW_SPEED: f64 = 1.25;
/// The armadillo's `FOLLOW_RANGE` attribute, the sensor's reach.
const FOLLOW_RANGE: f64 = 16.0;
impl BabyFollowAdult {
/// `NearestVisibleLivingEntities.findClosest(same type and not a baby)`, of the living
/// entities `NearestLivingEntitySensor.doTick` finds: those whose boxes meet the baby's box
/// inflated by its `FOLLOW_RANGE` (16, `Mob.createMobAttributes`).
/// `NearestVisibleLivingEntities.findClosest(same type and not a baby)`.
fn nearest_adult(m: &mut Mob, cx: &mut Cx) -> Option<u32> {
let species = m.species();
let area = m
.bounds()
.inflate(Vec3::new(FOLLOW_RANGE, FOLLOW_RANGE, FOLLOW_RANGE));
let p = m.position;
let area = Aabb::new(
Vec3::new(p.x - FOLLOW_MAX, p.y - FOLLOW_MAX, p.z - FOLLOW_MAX),
Vec3::new(p.x + FOLLOW_MAX, p.y + FOLLOW_MAX, p.z + FOLLOW_MAX),
);
let ids: Vec<(u32, f64)> =
cx.w.others_near(area)
.filter(|s| s.alive && !s.baby && s.species == species && s.bounds.intersects(area))
.filter(|s| s.alive && !s.baby && s.species == species)
.map(|s| (s.id, m.distance_sqr(s.position)))
.collect();
let mut best: Option<(u32, f64)> = None;
@@ -779,29 +780,6 @@ mod tests {
});
}
#[test]
fn a_baby_follows_only_adults_its_sensor_box_meets() {
// NearestLivingEntitySensor.doTick: the entities whose boxes meet the baby's box inflated
// by FOLLOW_RANGE (16). Both adults are within BabyFollowAdult's 17 blocks. The baby's box
// (0.35 to 0.7 wide, by its age scale) inflated reaches x = 16.675 to 16.85: the adult at
// x = 17.0 (0.7 wide, its box from 16.65) is found, the one at x = 17.2 (from 16.85) is
// not, though a scan of every mob returns it.
for (x, follows) in [(17.0, true), (17.2, false)] {
let Some([mut baby, adult]) = real([[0.5, 64.0, 0.5], [x, 64.0, 0.5]]) else {
return;
};
baby.extra.age = -24000;
crate::mobs::world::with_test_world(&[adult.seen(None)], |w, _| {
run_goals(&mut baby, w, 1);
assert_eq!(
baby.ai.goals.is_running("BabyFollowAdult"),
follows,
"x = {x}"
);
});
}
}
#[test]
fn a_baby_stays_when_the_adult_is_within_five_blocks() {
// BabyFollowAdult: `!closerThan(adult, 5)`.
@@ -1,105 +0,0 @@
use super::*;
/// A cow in love at (0.5, 64, 0.5) and single in-love cows at `mates`, on
/// a flat grass shelf, one tick of its goals: whether its `Breed` goal
/// took a mate, and the block it walks to. `None` without the vanilla
/// data.
fn courtship(mates: &[[f64; 3]]) -> Option<(bool, Option<[i32; 3]>)> {
// A mate's `Seen` needs its kind, so two loads.
let mut kind = None;
crate::mobs::world::with_built_test_world(&[], shelf, |_, types| {
kind = Some(Arc::new(types.types["minecraft:cow"].clone()));
});
let kind = kind?;
let seen: Vec<Seen> = mates
.iter()
.enumerate()
.map(|(i, [x, y, z])| {
let mut p = Mob::new(i as u32 + 2, kind.clone(), Vec3::new(*x, *y, *z), 0.0);
p.extra.in_love = 600;
p.seen(None)
})
.collect();
let mut out = None;
crate::mobs::world::with_built_test_world(&seen, shelf, |w, _| {
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.in_love = 600;
m.on_ground = true;
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
m.ai.goals = goals;
out = Some((m.ai.goals.is_running("Breed"), m.ai.nav.target_pos));
});
out
}
/// A flat grass shelf the cows stand and walk on.
fn shelf(world: &mut crate::world::World, registry: &horizon_world::registry::BlockRegistry) {
let grass = registry.default_state("minecraft:grass_block").unwrap();
for cx in -3..3 {
for cz in -3..3 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -16..=16 {
for z in -16..=16 {
world.set(x, 63, z, grass).unwrap();
}
}
}
/// `BreedGoal.getFreePartner` searches the box `getBoundingBox().inflate(8.0)`
/// but keeps only what `PARTNER_TARGETING` (`forNonCombat().range(8.0)`)
/// passes, and `TargetingConditions.test` rejects a target whose
/// `distanceToSqr` is above 8 squared.
#[test]
fn a_mate_outside_the_eight_block_sphere_is_ignored() {
// 7,0,5 apart: squared 74, inside the box, outside the sphere.
let Some((running, target)) = courtship(&[[7.5, 64.0, 5.5]]) else {
return;
};
assert!(!running, "a mate 8.6 blocks away is out of range");
assert_eq!(target, None, "it does not walk to it");
// Just outside: 8 on x and 0.6 on z, squared 64.36.
let Some((running, target)) = courtship(&[[8.5, 64.0, 1.1]]) else {
return;
};
assert!(!running, "a mate just past eight blocks is out of range");
assert_eq!(target, None, "it does not walk to it");
}
/// The other half of the same predicate: the sphere keeps a diagonal mate
/// and one exactly eight blocks away, and the nearest of two still wins.
#[test]
fn a_mate_inside_the_eight_block_sphere_is_courted() {
// 5,0,5 apart: squared 50.
let Some((running, target)) = courtship(&[[5.5, 64.0, 5.5]]) else {
return;
};
assert!(running, "a diagonal mate 7.1 blocks away is in range");
let [x, _, z] = target.expect("it walks toward its mate");
assert!((x, z) == (5, 5), "it heads for its mate, got {x} {z}");
// Exactly eight: squared 64 is not above the limit.
let Some((running, target)) = courtship(&[[8.5, 64.0, 0.5]]) else {
return;
};
assert!(running, "a mate exactly eight blocks away is in range");
let [x, _, z] = target.expect("it walks toward its mate");
assert!((x, z) == (8, 0), "it heads for its mate, got {x} {z}");
// Two mates: the nearer (5,5) wins over the one at exactly eight.
let Some((running, target)) = courtship(&[[8.5, 64.0, 0.5], [5.5, 64.0, 5.5]]) else {
return;
};
assert!(running);
let [x, _, z] = target.expect("it walks toward its mate");
assert!((x, z) == (5, 5), "the nearest mate, got {x} {z}");
}
@@ -1,90 +0,0 @@
use super::*;
/// Two llamas in love, two blocks apart on a flat grass shelf, ticked by
/// the AI until the first breeds: its own events, with the `mob_drops`
/// game rule at `drops`. `None` without the vanilla data.
fn llama_events(drops: bool) -> Option<Vec<MobEvent>> {
// The partner's `Seen` needs its kind, so two loads.
let mut kind = None;
crate::mobs::world::with_built_test_world_rules(&[], true, drops, shelf, |_, types| {
kind = Some(Arc::new(types.types["minecraft:llama"].clone()));
});
let kind = kind?;
let mut partner = Mob::new(2, kind.clone(), Vec3::new(2.5, 64.0, 0.5), 0.0);
// AbstractHorse.canParent: only tame llamas breed.
partner.extra.tame = true;
partner.extra.in_love = 600;
let seen = [partner.seen(None)];
let mut out = None;
crate::mobs::world::with_built_test_world_rules(&seen, true, drops, shelf, |w, _| {
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.in_love = 600;
m.extra.tame = true;
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
for _ in 0..600 {
crate::mobs::ai::tick(&mut m, w, &mut lr, &mut events);
if events
.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. }))
{
break;
}
}
out = Some(events);
});
out
}
/// A flat grass shelf two llamas can stand on.
fn shelf(world: &mut crate::world::World, registry: &horizon_world::registry::BlockRegistry) {
let grass = registry.default_state("minecraft:grass_block").unwrap();
for cx in -3..3 {
for cz in -3..3 {
world
.chunks
.insert((cx, cz), Arc::new(crate::chunk::Chunk::empty(cx, cz)));
}
}
for x in -8..=8 {
for z in -8..=8 {
world.set(x, 63, z, grass).unwrap();
}
}
}
/// `finalizeSpawnChildFromBreeding` makes the experience draw only inside
/// the `mob_drops` check, from the breeding parent's own random: the
/// breed goal pushes one 1-7 orb with the rule on, none with it off, and
/// the pair breeds either way.
#[test]
fn the_breed_xp_follows_the_mob_drops_rule() {
let Some(on) = llama_events(true) else {
return;
};
let Some(off) = llama_events(false) else {
return;
};
let orb = |events: &[MobEvent]| {
events.iter().find_map(|e| match e {
MobEvent::Experience { amount, .. } => Some(*amount),
_ => None,
})
};
assert!(
orb(&on).is_some_and(|a| (1..=7).contains(&a)),
"mob_drops on: one orb of 1-7, got {:?}",
orb(&on)
);
assert!(orb(&off).is_none(), "mob_drops off: no experience draw");
assert!(
on.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. })),
"mob_drops on: the pair breeds"
);
assert!(
off.iter()
.any(|e| matches!(e, MobEvent::Breed { parent: 1, .. })),
"mob_drops off: the pair still breeds"
);
}
+14 -253
View File
@@ -7,7 +7,7 @@
//! `cat_collar` to `cat`).
//!
//! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files
//! or old mob textures ([`LEGACY`]) every model stays vanilla's, and a file no layer uses isn't parsed.
//! nothing is built and every model stays vanilla's, and a file no layer uses isn't parsed.
//!
//! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's
//! vanilla texture name, and the random texture lookup then gets that name. Variants without
@@ -34,7 +34,7 @@ pub struct CemModels {
layers: HashMap<String, CemLayer>,
}
/// A model layer as a `.jem` reshapes it, or as a pack's pre-1.21.5 mob texture needs it.
/// A model layer as a `.jem` reshapes it.
pub struct CemLayer {
pub model: Arc<Part>,
/// The `.jem`'s animations, run after the vanilla animation.
@@ -45,34 +45,19 @@ pub struct CemLayer {
/// The `attachments` the pack's bones declare, by the bone's `cem#<n>` name: the item
/// anchors a renderer looks up (`right_handheld_item`, `left_handheld_item`, `fox_item`).
pub anchors: Vec<(String, String, [f32; 3])>,
/// The packs' pre-1.21.5 textures this layer paints with, by the vanilla texture they
/// stand for without its climate ([`legacy_stem`]): `entity/cow/mooshroom_red`
/// is drawn as `entity/cow/red_mooshroom`.
legacy: Vec<(&'static str, &'static str)>,
}
impl CemLayer {
/// What this layer draws in place of the vanilla `texture`: the `.jem`'s own, or the old
/// name of a texture 1.21.5 renamed when the packs paint this layer with it.
pub fn texture_for(&self, texture: &str) -> Option<&str> {
self.texture.as_deref().or_else(|| {
let stem = legacy_stem(texture);
self.legacy
.iter()
.find(|(new, _)| *new == stem)
.map(|(_, old)| *old)
})
}
}
/// How many fallbacks a chain follows (`cat_baby_collar`, `cat_collar`, `cat` takes two).
const FALLBACK_STEPS: usize = 3;
impl CemModels {
/// The `.jem` files under `root` applied to the layers of `types`, and the layers the
/// packs' pre-1.21.5 mob textures paint ([`LEGACY`]).
/// The `.jem` files under `root` applied to the layers of `types`.
pub fn load(root: &Path, types: &HashMap<String, Arc<MobType>>) -> Self {
let files = jem_files(root);
if files.is_empty() {
set_rule_species(Vec::new());
return Self::default();
}
let (models, standing) = Self::from_files(root, &files, &vanilla_layers(types));
set_rule_species(standing);
models
@@ -119,8 +104,6 @@ impl CemModels {
// Each file parsed once, when a layer first tries it.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut layers = HashMap::new();
// The layers that took their own file (`pig.jem` for `pig#main`), for [`LEGACY`].
let mut own = HashMap::new();
// The species whose rules read the standing block, for the per-tick gate.
let mut standing_species = Vec::new();
for (key, mut candidates) in plan {
@@ -154,20 +137,9 @@ impl CemModels {
// only: the layer keeps its own texture.
if layer_suffix(&key) != layer_suffix(&layer_key(name)) {
layer.texture = None;
} else if key == layer_key(name) {
own.insert(key.clone(), (name, path));
}
layers.insert(key, layer);
}
let own: HashMap<String, (&str, &str, &EntityModel)> = own
.into_iter()
.filter_map(|(key, (name, path))| {
Some((key, (name, path, parsed.get(path)?.as_ref()?)))
})
.collect();
for family in LEGACY {
family.layers(root, vanilla, &own, &mut layers);
}
(Self { layers }, standing_species)
}
@@ -632,10 +604,13 @@ impl CemLayer {
// beside the pack's own.
hide_unnamed(node, &named);
}
// `this` is the entry's own bone, replacing or attached ("current custom model",
// `cem_animation.txt`); `part` the vanilla part it hangs on. Conquest's pig writes
// `this.tx` = its `translate`: on the vanilla head that flung the head 7 px off.
entries.push((bone.name.clone(), target, p.animations.as_slice()));
// A replacing entry stands in for the vanilla part; an attached one is its own.
let this = if p.attach {
bone.name.clone()
} else {
target.clone()
};
entries.push((this, target, p.animations.as_slice()));
node.children.push(bone);
}
let names = super::cem_anim::Names {
@@ -649,137 +624,10 @@ impl CemLayer {
animation,
texture: jem.texture.as_deref().and_then(atlas_name),
anchors,
legacy: Vec::new(),
}
}
}
/// A mob 1.21.5 split by climate or renamed the texture of: a pack made before paints the
/// adult and its climate variants with its one old texture (`entity/pig/pig`, 64x32), whose
/// UVs the adult's model measures.
struct Legacy {
/// The adult's model; its climate variants', which the pack knew as the adult.
model: &'static str,
variants: &'static [&'static str],
/// The vanilla textures without climate ([`legacy_stem`]), and their old names.
textures: &'static [(&'static str, &'static str)],
/// How many times taller the new texture is than the old: the texels the old model read
/// kept their places.
taller: f32,
}
const LEGACY: &[Legacy] = &[
Legacy {
model: "pig",
variants: &["cold_pig"],
textures: &[("entity/pig/pig", "entity/pig/pig")],
taller: 2.0,
},
Legacy {
model: "cow",
variants: &["cold_cow", "warm_cow"],
textures: &[("entity/cow/cow", "entity/cow/cow")],
taller: 2.0,
},
Legacy {
model: "mooshroom",
variants: &[],
textures: &[
("entity/cow/mooshroom_red", "entity/cow/red_mooshroom"),
("entity/cow/mooshroom_brown", "entity/cow/brown_mooshroom"),
],
taller: 2.0,
},
Legacy {
model: "chicken",
variants: &["cold_chicken"],
textures: &[("entity/chicken/chicken", "entity/chicken")],
taller: 1.0,
},
];
/// A vanilla mob texture without its climate: `entity/pig/pig_cold` is `entity/pig/pig`.
fn legacy_stem(texture: &str) -> &str {
["_temperate", "_warm", "_cold"]
.iter()
.find_map(|climate| texture.strip_suffix(climate))
.unwrap_or(texture)
}
impl Legacy {
/// The family's layers (adult and climate variants) when the packs have an old texture of
/// it: the adult's vanilla model, reshaped by its own `.jem` when there is one without a
/// texture of its own, read in the old layout. Without this a pig drew the 64x64 texture
/// through a `.jem`'s 64x32 UVs, and cold pigs and mooshrooms vanilla's textures.
fn layers(
&self,
root: &Path,
vanilla: &HashMap<&str, (&Arc<Part>, &str)>,
own: &HashMap<String, (&str, &str, &EntityModel)>,
layers: &mut HashMap<String, CemLayer>,
) {
// `root` holds vanilla's files too, which never had the old names.
let legacy: Vec<_> = self
.textures
.iter()
.copied()
.filter(|(_, old)| {
root.join(format!("assets/minecraft/textures/{old}.png"))
.is_file()
})
.collect();
let adult = format!("{}#main", self.model);
let Some(&(part, _)) = vanilla.get(adult.as_str()) else {
return;
};
let jem = own.get(&adult);
if legacy.is_empty() || jem.is_some_and(|(_, _, jem)| jem.texture.is_some()) {
return;
}
let rules = jem.map(|&(_, path, _)| load_rules(root, path));
for model in std::iter::once(self.model).chain(self.variants.iter().copied()) {
let key = format!("{model}#main");
// A variant with a file of its own was made after 1.21.5.
if !vanilla.contains_key(key.as_str()) || key != adult && own.contains_key(&key) {
continue;
}
let tree = (**part).clone();
let mut layer = match (jem, &rules) {
(Some(&(name, _, jem)), Some(rules)) => CemLayer::build(&tree, jem, name, rules),
_ => CemLayer {
model: Arc::new(tree),
animation: None,
texture: None,
anchors: Vec::new(),
legacy: Vec::new(),
},
};
old_layout(Arc::make_mut(&mut layer.model), self.taller);
layer.legacy = legacy.clone();
layers.insert(key, layer);
}
}
}
/// The vanilla parts of `part` (not a `.jem`'s `cem#` bones) reading a texture `taller` times
/// shorter. Faces past its bottom belong to parts newer than it (the 1.21.5 cow's muzzle):
/// the model it was painted for had none.
fn old_layout(part: &mut Part, taller: f32) {
if part.name.starts_with("cem#") {
return;
}
for polygon in &mut part.polygons {
for vertex in &mut polygon.vertices {
vertex[4] *= taller;
}
}
part.polygons
.retain(|p| p.vertices.iter().all(|v| v[4] <= 1.0));
for child in &mut part.children {
old_layout(child, taller);
}
}
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
if part.name == name {
@@ -1032,75 +880,6 @@ mod tests {
assert!(models.layer("sheep#main").is_none());
}
#[test]
fn a_pre_1_21_5_pig_paints_with_its_old_texture() {
// Conquest's pig: the head replaced, the legs left vanilla.
let pig = r#"{"textureSize": [64, 32], "models": [{"part": "head", "translate": [5, 9.7, -11],
"boxes": [{"coordinates": [-11, -14, 3, 8, 8, 8], "uvNorth": [8, 8, 16, 16]}]}]}"#;
let mut leg = plane("right_hind_leg", vec![]);
leg.polygons[0].vertices[2][4] = 0.3125; // v 20 of the 64x64 texture
let tree = plane("root", vec![plane("head", vec![]), leg]);
let files = [("optifine/cem/pig.jem", pig)];
let new = models("legacy-new", &files, &["pig#main"], &tree);
assert_eq!(
new.layer("pig#main")
.unwrap()
.texture_for("entity/pig/pig_temperate"),
None
);
let files = [files[0], ("textures/entity/pig/pig.png", "")];
let old = models("legacy-old", &files, &["pig#main"], &tree);
let layer = old.layer("pig#main").unwrap();
assert_eq!(
layer.texture_for("entity/pig/pig_warm"),
Some("entity/pig/pig")
);
// The leg's v 20 is 20 of the old texture's 32; the pack's own box keeps its UVs.
let leg = get(&layer.model, "right_hind_leg");
assert_eq!(leg.polygons[0].vertices[2][4], 0.625);
assert_eq!(get(&layer.model, "cem#0").polygons[0].vertices[0][4], 0.25);
}
#[test]
fn a_pre_1_21_5_pack_paints_variants_with_the_adults_old_texture() {
// A pig without a `.jem`, its head with a face past the old texture's bottom.
let mut head = plane("head", vec![]);
let mut newer = head.polygons[0].clone();
newer.vertices[2][4] = 0.578125; // v 37 of 64: past the old 32
head.polygons.push(newer);
let tree = plane("root", vec![head]);
let keys = ["pig#main", "cold_pig#main"];
let files = [("textures/entity/pig/pig.png", "")];
let pigs = models("legacy-family", &files, &keys, &tree);
let cold = pigs.layer("cold_pig#main").unwrap();
assert_eq!(
cold.texture_for("entity/pig/pig_cold"),
Some("entity/pig/pig")
);
assert_eq!(get(&cold.model, "head").polygons.len(), 1);
// A cold pig with a file of its own was made for its own texture.
let file = format!(r#"{{"models": [{{"part": "head", {BOX}}}]}}"#);
let files = [files[0], ("optifine/cem/cold_pig.jem", file.as_str())];
let pigs = models("legacy-own-variant", &files, &keys, &tree);
let cold = pigs.layer("cold_pig#main").unwrap();
assert_eq!(cold.texture_for("entity/pig/pig_cold"), None);
// Mooshrooms take each colour's old name.
let files = [("textures/entity/cow/red_mooshroom.png", "")];
let moosh = models("legacy-mooshroom", &files, &["mooshroom#main"], &tree);
let layer = moosh.layer("mooshroom#main").unwrap();
let red = layer.texture_for("entity/cow/mooshroom_red");
assert_eq!(red, Some("entity/cow/red_mooshroom"));
assert_eq!(layer.texture_for("entity/cow/mooshroom_brown"), None);
// Without the old names nothing changes.
let files = [("textures/entity/pig/pig_temperate.png", "")];
assert!(
models("legacy-modern", &files, &keys, &tree)
.layer("pig#main")
.is_none()
);
}
const BOX: &str = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A `.jem` with one entry on `part`, attached or replacing it.
@@ -1239,24 +1018,6 @@ mod tests {
assert_eq!(attached(&models, "zombie_baby#main", "body"), Some(true));
}
#[test]
fn this_is_a_replacing_entrys_own_bone() {
// Conquest's pig: the head replaced, posed through `this`.
let pig = r#"{"models": [{"part": "head", "translate": [5, 9.7, -11],
"boxes": [{"coordinates": [-11, -14, 3, 8, 8, 8], "uvNorth": [8, 8, 16, 16]}],
"animations": [{"this.tx": "7"}]}]}"#;
let tree = plane("root", vec![plane("head", vec![])]);
let files = [("optifine/cem/pig.jem", pig)];
let models = models("this-bone", &files, &["pig#main"], &tree);
let layer = models.layer("pig#main").unwrap();
let mut poses = super::super::anim::Poses::new(&layer.model);
let animation = layer.animation.as_ref().unwrap();
animation.run_with(1, 1, |_| {}, &mut poses);
// `this.tx` writes the pack's bone; the vanilla head it replaces stays put.
assert_eq!(poses.get("cem#0")[0], 7.0);
assert_eq!(poses.get("head")[0], 0.0);
}
#[test]
fn an_unknown_part_is_skipped_and_the_rest_applies() {
let file =
+1 -7
View File
@@ -444,13 +444,7 @@ impl Animation {
}
/// `run` for the entity `id` in the frame `counter`, whose parameters `fill` writes.
pub(super) fn run_with(
&self,
id: u32,
counter: u32,
fill: impl FnOnce(&mut [f32]),
poses: &mut Poses,
) {
fn run_with(&self, id: u32, counter: u32, fill: impl FnOnce(&mut [f32]), poses: &mut Poses) {
let preview = IN_GUI.get();
SLOTS.with_borrow_mut(|slots| {
slots.clear();
@@ -338,11 +338,6 @@ impl Mob {
}
/// LookControl.tick (and SmoothSwimmingLookControl).
pub fn look_control_tick(&mut self) {
// Phantom.PhantomLookControl.tick is empty: the pitch the move control
// sets from the flight stays.
if self.species() == super::Species::Phantom {
return;
}
let look = self.ai.look.clone();
let fox = self.species() == super::Species::Fox;
// Fox.FoxLookControl: no turning in sleep.
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more