Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 498d9bebcf Add GOVERNANCE.md: the council, theme groups and how roles are earned
The governance the founder adopted on 2026-11-09 as his last decision as
head of the project: a council of equals deciding by 24-hour polls, theme
groups with leads, the selection filter, moderation and appeals, the 18+
rule for roles of trust, the agents, and the account handover path.
README and CONTRIBUTING link to it with one line each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 21:21:45 +01:00
Ludovic Marie f0cb1e42c0 Merge pull request #14156 from horizon-rust/layers/studio-gather
🤖 Agent: the verify failure was the two rustc-compiled fixtures (test_compute_fov_hook, test_world_input_capture) still modelling game.studio_freecam/studio_replay; they now carry the Studio struct game.rs reads, and both pass on this head.
2026-11-09 21:06:48 +01:00
Horizon agentandClaude Opus 5.5 1498fdecd3 Tests: fixtures read Studio's state from game.studio
test_compute_fov_hook and test_world_input_capture paste fov.rs and the
pointer-capture methods into a fixture Game; those now read
self.studio.{freecam,replay,rmb_down}, so the fixture carries a Studio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:55:14 +01:00
Horizon agentandClaude Opus 5.5 93e0536aca Studio: its state in one struct, game/studio.rs's Studio (layer plan M6)
Game's ten studio_* fields and last_replay_tick become one field, studio:
studio::Studio. Pure gather: every access is renamed, nothing else changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:29:54 +01:00
15 changed files with 743 additions and 238 deletions

No files matched your search

+2
View File
@@ -198,3 +198,5 @@ new pushes, we add it again.
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
How Horizon is run, and how people join the Team or lead a group: [GOVERNANCE.md](GOVERNANCE.md).
+483
View File
@@ -0,0 +1,483 @@
# Horizon governance
> Adopted 2026-11-09 by the founder as his last decision as head of the project. It takes effect when he
> announces that he is stepping down. From then on only the council changes it, by a 2/3 poll (section 15).
Horizon has no boss. A **council of equals** decides by polls. **Theme groups** do the work in their areas, each
with a **lead**. The **Horizon Team** reviews and merges code. **Moderators** keep the Discord server and the
official server safe. Every role of trust is earned with work the project can measure, and it ends when that work
stops.
## Contents
1. [Principles](#1-principles)
2. [The levels at a glance](#2-the-levels-at-a-glance)
3. [Each level in detail](#3-each-level-in-detail)
4. [The council and its polls](#4-the-council-and-its-polls)
5. [The selection filter](#5-the-selection-filter)
6. [Theme groups](#6-theme-groups)
7. [Moderation and appeals](#7-moderation-and-appeals)
8. [Conflicts and hard cases](#8-conflicts-and-hard-cases)
9. [The age rule (18+)](#9-the-age-rule-18)
10. [The agents](#10-the-agents)
11. [Accounts and the handover path](#11-accounts-and-the-handover-path)
12. [Money](#12-money)
13. [Transparency](#13-transparency)
14. [Rules that are never voted on](#14-rules-that-are-never-voted-on)
15. [Changing this document](#15-changing-this-document)
## 1. Principles
- **Equals.** Every council member has one vote. Nobody, the founder included, stands above the others.
- **The founder.** Ludovic keeps the title of **Founder**: his role stays at the top of the member list and he is
credited as Horizon's founder everywhere. The title is symbolic. It carries no extra vote or power: on the council
he counts exactly like every other member.
- **Evidence, not popularity.** You get a role by doing the work first. Kira measures that work: merged pull
requests, reviews, triage, support answers and days active. A vote then confirms it.
- **Small, clear powers.** Each role can do a short, written list of things. Anything else goes to the council.
- **Roles are on loan.** Every role has a 30-day probation and an inactivity rule. Stepping down for inactivity is
not a sanction, and you can come back.
- **Public by default.** Polls, results and rules are public. Only matters about a person stay private, and even then
the result is published.
- **18+ for roles of trust** (section 9).
- **Mojang's and Microsoft's rules come first.** They are never put to a vote (section 14).
## 2. The levels at a glance
From the most open level to the most trusted. A person can hold several levels at once: a group lead can also be on
the Horizon Team, for example.
| Level | What it can do | How you get in | How you leave | 18+ |
| --- | --- | --- | --- | --- |
| **Member** | Play, chat, report bugs, suggest ideas, open issues and pull requests, pick group roles | Join the Discord server or the GitHub repo | Leave, or get banned | No |
| **Contributor** (⛏️ Iron, 🥇 Gold, 💎 Diamond, 🟪 Netherite ranks) | Write in #contributors; `/claim` issues; nominate people for group lead (if active in that group) | One merged pull request in a public horizon-rust repo, linked with `/link-github` and `/verify-github`. Ranks come from merged pull requests: 10, 40, 100 and 200 | Contributor status and ranks stay | No |
| **Group member** | Read and write in the group's channel; get the group's pings; take part in the group's advisory poll on its lead | Pick the group's role in Channels & Roles, or with `/groups` | Remove the role | No |
| **🤝 Support** | Answer and close posts in #horizon-support | `/apply`; vote of the mods and the Team (as today) | Resign, 30 days inactive, or removed by the Chief of Mods or the council | No |
| **🛡️ Moderator** | Time out, delete messages, manage threads, quarantine. No bans | `/apply`, then the selection filter; mods' vote; the council may object within 24 h | Resign, 30 days inactive, suspended by the Chief of Mods, removed by a council poll (simple majority) | **Yes** |
| **🛡️ Chief of Mods** | A moderator's powers, plus bans; leads the mods; suspends a mod at once pending the council | Council poll (simple majority), from the mods with at least 30 days | Resign, 30 days inactive, council poll (simple majority) | **Yes** |
| **🧭 Group lead** | Decides inside the group's topic (section 6); manages the group's channel; triage on the group's GitHub issues | Nomination, the filter, a 24 h public discussion with an advisory group poll, then a council poll (simple majority); 30 days probation | Resign, 30 days inactive (automatic), council poll (simple majority); confirmed again every 6 months | **Yes** |
| **🌌 Horizon Team** | Review and merge pull requests (GitHub `horizon-team`, write access); `/nightly` and triage commands (trusted reviewers); #horizon-team | Nomination, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, 60 days without a merge or review (automatic), council poll (2/3, without the person) | **Yes** |
| **🏛️ Council** | Decides everything else by poll (section 4); bans; emergency actions by any 2 members | Nominated by a council member, the filter, a 24 h public discussion, then a council poll (2/3); 30 days probation | Resign, inactivity (section 5.5), council poll (2/3, without the person) | **Yes** |
**The account holder** isn't a level. The founder (Ludovic) still holds the project's accounts (GitHub organisation
owner, Discord server owner, the Microsoft app approved by Mojang, the domains, Railway, money). He carries out the
council's decisions on them and hands them over step by step (section 11). As a council member he has one vote,
like everyone else.
## 3. Each level in detail
### Member
Anyone in the Discord server or on GitHub. Members follow the server rules and Horizon's
[contributing guide](CONTRIBUTING.md). They can propose anything: an idea in #horizon-ideas, a bug in #horizon-bugs,
an issue or a pull request on GitHub, or a request for a council poll (any council member can pick it up).
### Contributor
- **Discord:** the 🛠️ Contributor role writes in #contributors and in the threads of #polls-and-decisions. A rank
role (⛏️ Iron at 10 merged pull requests, 🥇 Gold at 40, 💎 Diamond at 100, 🟪 Netherite at 200, counted over every
public horizon-rust repo) shows how much has been merged. Ranks only go up.
- **GitHub:** no repository role. Anyone can `/claim` and `/unclaim` their own issues.
- **Alone:** their own work.
### Group member
Each theme group (section 6) has a role that anyone can pick and drop. It opens the group's channel and receives the
group's pings. Picking a role grants no power. A group member counts as **active in the group** when Kira has seen at
least one counted action in the group's area (section 5.1) in the last 60 days. Only active group members can
nominate a lead or vote in the group's advisory poll.
### 🤝 Support
Unchanged: Support members help players in #horizon-support. They answer every post there and mark posts solved.
The role carries no permission, so it has no age rule. They join through `/apply`, and the mods and the Team vote.
### 🛡️ Moderators and the Chief of Mods
- **Discord:** time out members, delete messages, manage threads and nicknames, read the audit log, quarantine
through Kira, and see the staff channels. **Moderators don't ban.** Bans are done by council members and the Chief of
Mods. Kira's automatic actions (the honeypot, clear scams, illegal images) stay as they are.
- **Official server:** server operators (ops) are moderators in game. They need the 18+ rule and are confirmed the
same way (section 6, Server and SMP).
- **Alone:** a timeout, a deleted message, a quarantine, with a reason in the mod case.
- **The Chief of Mods** is the first contact for moderation questions. They organise the mods, can ban, and can take a
moderator's role away at once when there is a problem. The council confirms or reverses that within 72 hours.
### 🧭 Group leads
Section 6 says what a lead decides alone and what goes to the council.
- **Discord:** in their group's channel only, delete messages, manage and pin threads, and ping the group role. The
🧭 lead role carries no server-wide permission.
- **GitHub:** triage on the issues and pull requests that carry their group's label (`/label`, `/unlabel`, `/close`,
`/reopen`, `/unclaim`), from a list of group leads that grants no repository role. Leads merge only if they are on
the Horizon Team too.
### 🌌 Horizon Team
The Horizon Team **merges pull requests**. Its rules are in [the release flow](docs/release-flow.md): read the whole
diff, never merge your own pull request, and use `needs-proof` for anything risky.
- **GitHub:** member of the `horizon-team` team (write access to the Horizon repos), on the trusted reviewers list
(`/nightly` and triage commands).
- **Discord:** the 🌌 Horizon Team role, #horizon-team, and writing in #contributors and the read-only channels.
Moderation permissions belong to the moderators and the council: a Team member moderates only if they also hold
one of those roles.
- **During the 30-day probation:** they merge only ordinary pull requests. They don't merge `needs-proof` pull
requests, don't approve files that CODEOWNERS guards, and don't use `/nightly`.
### 🏛️ Council
- **Discord:** the 🏛️ Council role: the moderators' permissions plus ban and mention @everyone; #council (private);
starting polls in #polls-and-decisions.
- **GitHub:** the `council` team. Over time it takes over the founder's CODEOWNERS review and his owner powers in the
release tools (section 11).
- **Alone (any one member):** start a poll; pick up a member's request for a poll; do anything their other roles
allow.
- **Any two members together:** an emergency action (section 4.6).
- **Everything else** is decided by poll.
## 4. The council and its polls
### 4.1 Founding council
kacpherek, levzzz5154, rdbtCVS, Online5277, dezer-x and Ludovic: six members, one vote each. The founding members
don't go through the filter or a probation. They do make the 18+ declaration (section 9). The council has at most
**9** members. It adds a seat only when a 2/3 poll says it needs one.
### 4.2 How a poll works
| Rule | Value |
| --- | --- |
| Who starts one | Any council member, with `/poll` (Kira). Anyone else asks a council member to start it |
| Where | #polls-and-decisions, in public. Polls about a person (removal, appeals, the age rule) run in #council, and only their result is public |
| How long | 24 hours. It closes early only if every council member has voted |
| Ballot | Yes, No or Abstain. You can change it until the poll closes |
| Quorum | At least half of the council votes (Abstain counts). With 6 members, 3 votes |
| Simple majority | More Yes than No |
| Two-thirds | Yes is at least 2/3 of the Yes and No votes, **and at least 3 Yes votes** |
| Tie | Nothing changes |
| No quorum | Nothing changes. Kira reminds the members who haven't voted 12 hours and 2 hours before the end |
| About yourself | You don't vote, and you don't count in the council's size for that poll |
| Asking again | A poll that failed can't be asked again for 7 days, unless two members agree that new facts justify it |
### 4.3 Which threshold applies
| Simple majority | Two-thirds |
| --- | --- |
| Day-to-day project decisions: direction, priorities, settling disputes between groups, a public statement | Adding council members and Horizon Team members |
| Group leads and co-leads: appointing, removing, confirming every 6 months | Removing a council member or a Team member (without the person concerned) |
| Moderators, the Chief of Mods, Support: confirming, removing | Rules: this document, the server rules, the contributing policy, the licence |
| Ending a probation early | Money (section 12) |
| Confirming an emergency action | Account handover steps (section 11) |
| Second-level moderation appeals | Waiving part of the selection filter (section 5.4) |
| Bringing back someone who stepped down for inactivity, within 6 months | Changing the council's size |
**When in doubt, two-thirds.** Any council member can say a poll needs 2/3, and Kira then counts it at 2/3.
### 4.4 Discussion
Polls that add someone to the council, the Team or a lead post start with a **24-hour public discussion** (section
5.3). Other polls are discussed in their own thread while they run. A member who wants more time votes No and says
why. A failed poll can come back after 7 days.
### 4.5 Conflicts of interest
A member doesn't vote on a poll about themselves: their own role, their own removal, an appeal of their own
moderation action, or money paid to them. They say so in the poll's thread and Kira leaves them out of the count.
### 4.6 Emergencies
For **security** (a leaked secret, a malicious pull request or mod, a hijacked account), **Mojang or Microsoft** (a
request from them, a risk to the approved sign-in) or **legal** matters (a takedown, the law), **any two council
members act at once**. They may:
- pause releases and the Nightly;
- take a mod down from the registry;
- remove someone's roles or access for now;
- lock or hide channels and content;
- ask the account holder to rotate a secret or a key.
They record it with `/emergency` in Kira, and both of them confirm it there. Kira opens a council poll at once
(simple majority) to confirm it. If the poll doesn't pass, the action is undone where it can be. An emergency never
adds members, changes rules or spends money.
### 4.7 Removing a council member
A 2/3 poll of the other members, in #council. The person concerned doesn't vote and doesn't count in the quorum.
Only the result is published, never the reasons. Other ways to leave: resigning, inactivity (section 5.5), or a
false age declaration (section 9).
### 4.8 When the council drops below a quorum
- **Fewer than 5 members:** Kira opens nominations for the council at once. The council fills seats within 30 days.
- **Fewer than 3 members, or fewer than 3 who voted in the last 30 days:** an **interim council** forms. It counts the
remaining council members, the Horizon Team and the group leads past probation. Its only task is adding council
members, by its own 2/3 poll with a quorum of half. Any two of its members can take emergency actions. All other
decisions wait. The Team keeps merging and the leads keep leading.
- **Nobody left:** the Team and the group leads form the interim council in the same way.
## 5. The selection filter
Nobody becomes a lead, a Team member or a council member just because they are well liked or talk a lot. Kira
measures the work, and only people above a floor can be nominated. The council then decides among them.
### 5.1 What Kira measures
| Signal | What counts | Source |
| --- | --- | --- |
| **Merged pull requests** | Pull requests merged in a public horizon-rust repo. For Modding, also in the mod API's fork (arismoko/horizon). Counted overall, and per group by label or by files touched | GitHub search, as the ranks are counted today |
| **Reviews** | Others' pull requests you reviewed: approved, asked for changes, or left a review with comments. One per pull request | GitHub search `reviewed-by:` and Kira's GitHub webhook |
| **Triage** | Triage commands run (`/label`, `/close`, duplicates), issues labelled or closed, bug posts routed on Discord | The triage workflow's comments and Kira's webhook |
| **Support answers** | #horizon-support posts you answered that ended solved | Kira's support feature |
| **Active days** | Distinct days with any of the above, or with messages in #contributors or a group's channel. Chat elsewhere doesn't count | Kira |
| **Tenure** | Days since your first counted contribution (since you joined the server, for moderators) | GitHub, Discord |
| **Clean record** | No timeout, quarantine or ban in the last 90 days | Kira's mod cases |
| **Verified GitHub** | `/verify-github` done | Kira |
| **18+ declaration** | Made (section 9) | Kira |
Kira turns these into an **evidence card**: the person's numbers, overall and in the group's area, for the last 60
days and in total. Anyone can see their own card with `/evidence`. A nomination shows the card publicly.
### 5.2 The floors
A nomination is refused below these floors.
| Role | Floor |
| --- | --- |
| **Group lead** | Verified GitHub; tenure of 30 days or more; **in the group's area: 8 or more merged pull requests, or 20 or more reviews** (Translations: 5 or more merged translation pull requests; Server and SMP: 8 pull requests, or 30 days as an official server operator with activity); 15 or more active days in the last 60; clean record; 18+ |
| **Moderator** | In the server for 30 days or more; Kira level 10 or more; 15 or more active days in the last 60; clean record; 18+ |
| **Chief of Mods** | Moderator for 30 days or more, past probation; 18+ |
| **Horizon Team** | 🥇 Gold rank (40 merged pull requests); 20 or more reviews of others' pull requests; tenure of 30 days or more; 20 or more active days in the last 60; clean record; verified GitHub; 18+ |
| **Council** | Horizon Team member, group lead or Chief of Mods for 60 days or more, past probation; 20 or more active days in the last 60; clean record; 18+ |
The numbers are checked again once a year (section 15). They should stay reachable for people who really work on
Horizon and out of reach for people who don't.
### 5.3 The path to a role
1. **Nomination.** Run `/nominate member: role: [group:]` in Kira.
- A **group lead** can be nominated by an active member of that group or by a council member. A self-nomination
needs a second from another active group member or a council member.
- A **Team member** can be nominated by a council or Team member. Someone can also nominate themselves with
`/apply`, if a council or Team member sponsors them.
- A **council member** can only be nominated by a council member.
- **Moderators** apply with `/apply`.
2. **The filter.** Kira checks the floor and the 18+ declaration. Below the floor, Kira refuses and says which line
is short.
3. **Public discussion, 24 hours.** Kira posts the nomination and the evidence card in a thread of
#polls-and-decisions. Contributors and the council can comment. For a lead, Kira also runs an **advisory poll** in
the group's channel for the same 24 hours. Only active group members vote in it, and the council sees the result.
4. **Council poll, 24 hours.** Simple majority for a lead, 2/3 for the Team and the council. For moderators, the mods'
vote replaces this step, and any two council members can object within 24 hours; the council then decides by poll.
5. **Probation, 30 days.** The role is held in full, except for the Team limits in section 3. At day 25, Kira asks the
council in #council whether anything is wrong. Probation ends by itself at day 30, unless a council poll (simple
majority) ended it earlier.
### 5.4 Exceptions
Some valuable people don't show up in the numbers, such as a designer for the website. The council can **waive the
tenure or the area floors** by a 2/3 poll that says why. The age rule and the clean record are never waived.
### 5.5 Inactivity
Kira checks every day. It warns the person in a DM and in #council, then posts the step-down in
#polls-and-decisions with neutral wording ("stepped down: inactive"), and removes the Discord role. The account
holder or an org owner removes GitHub access when the Team role ends.
| Role | What counts as activity | Warning | Steps down |
| --- | --- | --- | --- |
| Group lead | A counted action in the group's area, or messages in the group's channel | Day 21 | Day 30 |
| Horizon Team | A merge or a review | Day 45 | Day 60 |
| Moderator, Chief of Mods | A moderation action, or messages in the staff channels | Day 21 | Day 30 |
| Support | An answer in #horizon-support | Day 21 | Day 30 |
| Council | A vote in a poll. At 30 days with no vote, while at least 2 polls were held | Day 23 | Day 30, or 45 days with no counted activity at all |
- **Away.** Anyone can announce an absence with `/away` for up to 45 days, once every 6 months. Away days don't
count. A lead names someone from the group to cover for them.
- **Coming back.** Within 6 months: a simple-majority poll, with no new probation. After 6 months: the normal path.
### 5.6 Terms
- **Group leads** are confirmed every 6 months by a simple-majority poll. If someone else in the group passes the
floor and is nominated, both go on the same poll.
- **Council and Team** have no fixed term. They stay as long as they are active and keep the council's trust. Removal
is always possible (section 4.7).
## 6. Theme groups
### 6.1 The groups
| Group | Covers | GitHub | Discord |
| --- | --- | --- | --- |
| **Modding** | The mod API, native and content mods, mixins, the mod registry, `cargo horizon-mod` | The mod API lives in Ari's fork **arismoko/horizon**. Its owner runs that repo by their own rules; the council governs only what is Horizon's: horizon-rust/horizon-mods, horizon-mixins, horizon-native-mod-template, the registry. Label `mods` | The 🧩 MODDING category (#modding, #horizon-modding-team); roles 🧩 Modder (picked) and 🧩 Horizon Modding Team (the lead gives it) |
| **Shaders and rendering** | The renderer, shaders, shader packs, OptiFine and Polytone pack formats | Label `rendering`; `crates/horizon-client-render` (gfx), `*.wgsl`, `horizon-shaders`, `horizon-shader-api`, `horizon-optifine`, `horizon-polytone` | #shaders-rendering |
| **Performance** | Frame rate, memory, server tick, benchmarks | Label `perf`; `horizon-bench`, the benchmark tools | #performance |
| **Launcher** | The launcher: installs, updates, instances, its pages | Label `launcher`; `launcher/` | #launcher |
| **Server and SMP** | Dedicated server, hosting, the anticheat, multiplayer, and the official SMP | Labels `server`, `multiplayer`; `horizon-server*`, `horizon-anticheat`, `lan/`, `dedicated*`; horizon-rust/horizon-server | #server-smp (plus #join-server, #server-chat) |
| **Translations** | The game's and the launcher's languages | Label `roadmap:languages`; `launcher/lang/`, `crates/horizon-client/assets/horizon/lang/` | #translations and the 🌍 language channels |
| **Website** | playhorizon.dev | horizon-rust/horizon-site; label `website`. The site's design stays as it is unless the council decides otherwise | #website |
| **Vanilla accuracy** | Parity with vanilla 26.3: harnesses, oracles, the port ledger | Labels `parity`, `parity-regression`, `full-parity`; `tools/test_*_parity.py`, the Java oracles | #vanilla-accuracy |
| **UI** | The game's menus, HUD, settings, controller navigation, the design system | Labels `ui`, `accessibility`; `title/`, `settings_shell.rs`, `hud*`, `ui.rs`, `docs/design-system.md`, `horizon-theme`. The launcher's look is shared with Launcher | #ui |
A pull request can belong to two groups. Both leads can comment, and the Team merges as usual. If the leads disagree,
the council decides.
### 6.2 Leads per group
- **One lead per group.** A group with 8 or more active members may also have **one co-lead**, chosen the same way.
- A person leads **at most two groups**. Council members may lead a group, though a lead from outside the council is
preferred: it spreads the work.
### 6.3 What a lead decides alone
Inside their group's topic, and within this document, the contributing guide, the vanilla-first rule, the layers and
the design system:
- the group's priorities and the order of its roadmap items;
- which issues are `good first issue`, and triage of the group's issues;
- technical and design direction inside the area, when vanilla behaviour and the default look don't change;
- accepting or declining an idea or a proposal in the area. Any two active group members or one council member can
take that decision to the council;
- how the group organises itself (threads, meetings, the channel's rules on top of the server's);
- asking the Team for reviews, adding `needs-proof`, and nominating people.
### 6.4 What goes to the council
- the rules, money, accounts and secrets;
- who is on the council, the Team or the mods;
- anything that changes vanilla behaviour or turns a Horizon-only feature on by default;
- breaking a public promise: the mod API, server compatibility, save and file formats;
- disputes between groups;
- anything involving Mojang, Microsoft or the law;
- speaking for the whole project outside the group's topic.
### 6.5 A group without a lead
The work goes on. The Team reviews and merges, and Kira still routes the group's issues and questions to its
channel. Until a lead is chosen, any council member may take a lead's decision for that group and says so in the
channel. Kira pins a "lead wanted" note with the floor. **No lead is better than a lead who doesn't do the work:**
nobody is appointed just to fill the seat.
## 7. Moderation and appeals
- **The server rules** are in #rules. Moderators enforce them, the Chief of Mods leads the mods, and the council can
change the rules (2/3).
- **Every action has a mod case**: who acted, what they did, and why. Kira keeps the cases.
- **First appeal:** within 14 days, through #appeal (for a quarantine) or mod mail. A moderator who wasn't involved,
or the Chief of Mods, answers within 72 hours.
- **Second appeal:** for bans, timeouts over 24 hours and quarantines, a council poll in #council (simple majority).
Members who were involved don't vote. The answer is final for 90 days.
- **Not appealable here:** actions that Discord's rules or the law require. Those are reported where the law says.
- **Removing a moderator:** the Chief of Mods can suspend a moderator at once, and the council decides within 72 hours
(simple majority). The council removes the Chief of Mods the same way.
- **Staff are moderated like everyone.** When a council member is involved, the council decides without them.
## 8. Conflicts and hard cases
| Case | What happens |
| --- | --- |
| Two groups disagree | The leads talk first. If they still disagree, the council decides (simple majority) |
| A lead's decision is contested | Any two active group members or one council member can bring it to the council |
| A Team member merges against the rules | Any council member can revert the merge. The council can remove the member (2/3) |
| A council member breaks the rules | The council decides without them. Removal takes 2/3 (section 4.7) |
| Someone holds a role and is under 18 | Section 9 |
| The council can't reach quorum | Section 4.8 |
| The founder and the council disagree about one of his own accounts | Until a step of section 11 hands it over, the account stays his. He carries out the council's decisions on project matters, or he says publicly that he won't |
## 9. The age rule (18+)
**Who must be 18 or older:** members of the council and the Horizon Team, trusted reviewers, group leads and
co-leads, the Chief of Mods and the moderators, official server operators, and anyone with admin rights anywhere in
the project: Discord (Administrator, Manage Roles, Manage Channels, ban), GitHub (write, admin, organisation),
server panels, Railway, or Kira's admin side. The same goes for anyone who holds a project secret.
**Who doesn't have to be:** members, contributors (with any rank), group members and 🤝 Support. Contributors under
18 are welcome and valued. They just can't hold the roles above.
**How it's checked, without collecting ID:**
- **A declaration.** Before taking such a role, and once at the start for everyone who holds one, the person presses
a button in Kira: "I confirm that I am 18 or older. I understand that a false declaration removes all my roles of
trust." Kira keeps only the Discord account, the date and the wording's version. It never stores a birth date, an
ID, a photo, or any outside age-checking service's result.
- **No declaration, no role.** At the start, current role holders have 7 days to make it. Until they do, their role is
paused.
- **When there's doubt** (the person says they are younger, or there is a credible private report), one council
member asks the person privately. If the doubt remains, any council member pauses the role, and the council decides
within 72 hours in #council.
- **A false declaration** removes every role of trust at once, and the person can't hold one for 12 months.
- **Privacy.** Age is never discussed in public. A role that ends for this reason ends with the same neutral line as
any other step-down.
## 10. The agents
Horizon uses AI agents: the founder's orchestrator, which runs coding agents, and Kira, the Discord bot.
- **They obey council decisions only:** a passed poll recorded by Kira, a recorded emergency action of two council
members, and the standing rules in this document and the project's docs.
- **The only exception is the founder's own things:** his spending, his machines (they run CI and the vanilla tests),
his personal accounts and his time. On those, his word decides. This list should get shorter over time (section 11).
- **Role work isn't a decision.** A lead asking Kira to route issues, or a Team member asking an agent to prove a pull
request, is that person using their role. The agents help, within the role's powers.
- **They check the source.** An agent acts on a decision only when it finds it in Kira's decisions log
(section 13). A message saying "the council decided", or a screenshot, is not enough.
- **They post as agents.** Everything an agent posts says it comes from an agent, and states decisions as the
project's.
## 11. Accounts and the handover path
Today the founder holds every account. The aim is for him to hold fewer, in an order that never leaves the project
unable to act, and never puts an account that is tied to a person into anyone else's hands.
| Account | Today | Can it be shared? | Plan |
| --- | --- | --- | --- |
| **GitHub organisation** (horizon-rust) | One owner (the founder) | Yes: an organisation can have several owners | **Step 1:** a second owner from the council, chosen by a 2/3 poll in the council's first week, with two-factor authentication on. **Step 2:** a `council` team; CODEOWNERS reviews and the release tools' owner powers (`paused`, `/nightly remove`) move from the founder to that team |
| **Discord server** | Owned by the founder | Partly: a server has exactly one owner; powers can be given through roles | **Step 1:** the 🏛️ Council role gets moderation and ban permissions; Kira applies the outcomes of passed polls, so nobody needs Manage Roles; two-factor authentication becomes required for moderation. Ownership itself stays with the founder until a legal entity exists |
| **Kira** (bot application, Railway service) | The founder's Discord developer account and Railway | The bot: yes (a Discord developer team). Railway: adding members may need a paid plan | **Step 1:** a Discord developer team with two council members. Railway members once the council decides on money |
| **Official server** (hosting panel) | The founder's account | Yes: panel sub-users | **Step 1:** sub-users for the Server and SMP lead and for one council member |
| **Domains** (playhorizon.dev and the others) | The founder's registrar account | Not without moving it | Auto-renewal on, registered for several years ahead. They move only to a legal entity, if one is created |
| **The Microsoft app approved by Mojang** (sign-in) | The founder's Azure app registration; Mojang approved it after his application | **No.** The approval was given on his application. Moving the app or adding owners would change who Mojang deals with, and we have found no documented way to transfer an approval | **Never moves** without Mojang's written agreement. The only change considered: a legal entity, if one is created, and only if Mojang agrees |
| **Signing** (Apple developer certificate, release signing keys) | The founder's | The Apple certificate is in his legal name: no | The Apple certificate never moves. Release keys stay in GitHub's protected environments, and the council team approves their use from step 2 |
| **Money** (sponsorship, donations) | Personal accounts of the founder | Through a fiscal host | Section 12 |
| **The founder's machines, AI accounts and password vault** | His | No | His own things: they never move. The project should depend on them less over time |
**Order:** first the safety nets that remove a single point of failure (a second organisation owner, the bot's
developer team, panel sub-users). Then the day-to-day powers (CODEOWNERS and release tools to the council team). Last,
whatever needs a legal entity: domains, money, and with Mojang's agreement the sign-in app. Each step is a 2/3 poll,
and the founder carries it out.
## 12. Money
*Placeholder: the options (Open Collective and others) are being researched.*
- The council approves every expense by a **2/3 poll**.
- **Every income and every expense is public.**
- Nobody votes on money paid to themselves.
## 13. Transparency
- **#polls-and-decisions:** every poll and every result, posted by Kira, and the 24-hour discussions of nominations.
For polls that aren't about a person, each member's vote is shown once the poll closes.
- **The decisions log:** Kira keeps an append-only record of every poll, emergency action, appointment and
step-down, with its date and result. It is public and machine-readable, so the agents can check a decision there.
- **This document** lives in horizon-rust/horizon as `GOVERNANCE.md`. A short summary is pinned in #rules and in
#polls-and-decisions.
- **Who holds which role:** the people page of playhorizon.dev's hub shows the council, the Team, the group leads,
the Chief of Mods, the moderators and Support.
- **#council** is private for discussion and for polls about people. Everything the council decides is published.
## 14. Rules that are never voted on
These are Horizon's conditions for existing, not choices. No poll can change them:
- Playing needs a **Microsoft account that owns Minecraft: Java Edition**. There is no play without one.
- **No Mojang files** in Horizon's repos or downloads. Players' own copies stay on their own machines.
- **No gambling**, in any form, including with in-game currency.
- **Mojang's and Microsoft's terms and Usage Guidelines** apply to everything Horizon does.
- **The law**, and the terms of service of Discord and GitHub.
## 15. Changing this document
A 2/3 council poll, then a pull request that a Team member merges. The council reviews the floors' numbers
(section 5.2) once a year, or sooner if they turn out wrong.
+2
View File
@@ -135,6 +135,8 @@ python3 tools/pr_ready.py
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
How Horizon is run (the council, theme groups and their leads, the Horizon Team, moderation): [GOVERNANCE.md](GOVERNANCE.md).
## Project status
**Player-visible gaps: 0 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Animals [0](https://github.com/horizon-rust/horizon/issues/3512); Monsters and bosses [0](https://github.com/horizon-rust/horizon/issues/3513); Entities, projectiles, vehicles and decorations [0](https://github.com/horizon-rust/horizon/issues/3514); Items, enchantments and inventories [0](https://github.com/horizon-rust/horizon/issues/3515); Blocks, redstone and fluids [0](https://github.com/horizon-rust/horizon/issues/3516); World, world generation and saves [0](https://github.com/horizon-rust/horizon/issues/3517); Screens, HUD and client packets [0](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [0](https://github.com/horizon-rust/horizon/issues/3519); Server and players on the server [0](https://github.com/horizon-rust/horizon/issues/3520); Mob AI, villagers and raids [0](https://github.com/horizon-rust/horizon/issues/3521). This number goes down as gaps close (`tools/parity_gaps.py`); the table below says what the harnesses compare.
+51 -71
View File
@@ -2882,19 +2882,8 @@ struct Game<'a> {
/// The open connection joined from Realms: `ServerData.isRealm` for the friends
/// service's presence status.
realm_session: bool,
/// Active packet replay session (game/studio.rs).
pub(super) studio_replay: Option<horizon_client::studio::ReplayDriver>,
pub(super) studio_freecam: horizon_client::studio::StudioFreecam,
pub(super) studio_timeline: horizon_client::studio::TimelineUi,
pub(super) studio_topbar: horizon_client::studio::TopBarUi,
pub(super) studio_rmb_down: bool,
studio_camera_track_dirty: bool,
/// Camera perspective to restore after leaving replay playback.
studio_replay_previous_camera_type: Option<perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
studio_replay_standalone: bool,
last_replay_tick: Option<Instant>,
pub(super) studio_virtual_tick_ms: f32,
/// Horizon Studio: replay playback, its free camera, timeline and top bar (game/studio.rs).
pub(super) studio: studio::Studio,
/// This world is open to the network (game/lan.rs).
lan: Option<lan::LanHost>,
/// Single player's server thread, which `remote` is connected to.
@@ -5942,7 +5931,7 @@ impl Game<'_> {
// the sleep overlay, debug screen and open screens still draw.
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let in_studio = self.studio_replay.is_some();
let in_studio = self.studio.replay.is_some();
let show_hud = !self.ui.hide_gui && !in_studio;
// Hud.extractRenderState asks `Screen.isInGameUi` twice: such a screen
// defers the subtitles over itself, and keeps them while F1 hides the
@@ -6284,19 +6273,19 @@ impl Game<'_> {
}
if !self.ui.hide_gui
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
&& (self.studio.replay.is_some() || self.studio.freecam.active)
{
self.studio_topbar.render(
self.studio.topbar.render(
&mut batch,
&self.gui.atlas,
self.cursor,
self.studio_freecam.locked_to_path,
self.studio.freecam.locked_to_path,
);
self.studio_timeline.render(
self.studio.timeline.render(
&mut batch,
&self.gui.atlas,
self.cursor,
&self.studio_freecam,
&self.studio.freecam,
);
}
if show_hud {
@@ -6521,12 +6510,12 @@ impl Game<'_> {
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
if self.studio_freecam.active {
if self.studio.freecam.active {
return camera_rotation(
perspective::CameraType::FirstPerson,
self.studio_freecam.yaw,
self.studio_freecam.pitch,
) * glam::Quat::from_rotation_z(self.studio_freecam.roll.to_radians());
self.studio.freecam.yaw,
self.studio.freecam.pitch,
) * glam::Quat::from_rotation_z(self.studio.freecam.roll.to_radians());
}
match self.posed_rotation {
// The frame's camera, already turned round in front view.
@@ -6733,8 +6722,8 @@ impl Game<'_> {
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.mark_motion(player_start, Self::MOTION_OWN_PLAYER);
let player_alpha = if self.studio_replay.is_some() {
(self.studio_virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
let player_alpha = if self.studio.replay.is_some() {
(self.studio.virtual_tick_ms as f64 / 50.0).clamp(0.0, 1.0)
} else {
self.clock.camera_fraction()
};
@@ -9236,7 +9225,7 @@ impl Game<'_> {
let camera_alpha = self.clock.camera_fraction();
let vanilla = self.camera_view(camera_alpha as f32);
let view = vanilla.map(|view| {
if self.shows_panorama() || self.studio_freecam.active {
if self.shows_panorama() || self.studio.freecam.active {
view
} else {
self.native_camera_pose_now(camera_alpha as f32, view)
@@ -9414,9 +9403,9 @@ impl Game<'_> {
// hitboxes (F3+G, F3+B).
let mut lines = self.fishing_line(alpha, size);
let mut g = self.debug_gizmos(alpha as f32, camera.to_array());
if self.studio_freecam.active || self.studio_replay.is_some() {
if self.studio.freecam.active || self.studio.replay.is_some() {
horizon_client::studio::render_camera_path(
&self.studio_freecam.track,
&self.studio.freecam.track,
&mut g,
None,
100,
@@ -9443,7 +9432,7 @@ impl Game<'_> {
Vec::new()
};
pr.lap("pr_native_visuals");
let view = if self.studio_freecam.active {
let view = if self.studio.freecam.active {
glam::Mat4::from_quat(self.camera_orientation().conjugate())
} else {
horizon_client::camera::view_matrix(view_yaw, view_pitch)
@@ -9569,7 +9558,7 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
let camera_effects = fov::camera_effects_enabled(title, self.studio_freecam.active);
let camera_effects = fov::camera_effects_enabled(title, self.studio.freecam.active);
let bob = if !camera_effects {
glam::Mat4::IDENTITY
} else {
@@ -10463,19 +10452,19 @@ impl Game<'_> {
let previous = self.cursor;
self.cursor = (position.x as f32 / b.scale, position.y as f32 / b.scale);
if self.screen == Screen::Playing
&& (self.studio_timeline.dragging_playhead
|| self.studio_timeline.dragging_keyframe.is_some())
&& (self.studio.timeline.dragging_playhead
|| self.studio.timeline.dragging_keyframe.is_some())
{
let action = self.studio_timeline.handle_mouse_drag(
let action = self.studio.timeline.handle_mouse_drag(
self.cursor,
b.width,
&mut self.studio_freecam,
&mut self.studio.freecam,
);
if matches!(
action,
horizon_client::studio::TimelineAction::KeyframeMoved
) {
self.studio_camera_track_dirty = true;
self.studio.camera_track_dirty = true;
}
return Ok(());
}
@@ -10527,7 +10516,7 @@ impl Game<'_> {
}
}
WindowEvent::MouseWheel { .. }
if self.screen == Screen::Playing && self.studio_replay.is_some() => {}
if self.screen == Screen::Playing && self.studio.replay.is_some() => {}
WindowEvent::MouseWheel { delta, .. } => {
let (raw_x, raw_y) = match delta {
MouseScrollDelta::LineDelta(x, y) => (f64::from(x), f64::from(y)),
@@ -10805,44 +10794,44 @@ impl Game<'_> {
WindowEvent::MouseInput { state, button, .. } => {
let pressed = state == ElementState::Pressed;
if !pressed && button == MouseButton::Left {
let action = self.studio_timeline.handle_mouse_up();
let action = self.studio.timeline.handle_mouse_up();
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
}
}
if (self.studio_freecam.active || self.studio_replay.is_some())
if (self.studio.freecam.active || self.studio.replay.is_some())
&& button == MouseButton::Right
{
if let Some(renderer) = &self.renderer {
let batch =
HudBatch::new([renderer.config.width, renderer.config.height]);
if self.studio_timeline.visible
&& self.cursor.1 >= batch.height - self.studio_timeline.height()
if self.studio.timeline.visible
&& self.cursor.1 >= batch.height - self.studio.timeline.height()
{
if pressed
&& let Some(index) = self.studio_timeline.keyframe_at_cursor(
&& let Some(index) = self.studio.timeline.keyframe_at_cursor(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
&self.studio.freecam,
)
{
if index < self.studio_freecam.track.keyframes.len() {
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
if index < self.studio.freecam.track.keyframes.len() {
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
}
self.studio_timeline.selected_keyframe = None;
self.studio.timeline.selected_keyframe = None;
}
return Ok(());
}
}
if pressed {
self.studio_topbar.close_dropdown();
self.studio_rmb_down = true;
self.studio.topbar.close_dropdown();
self.studio.rmb_down = true;
self.capture(true);
return Ok(());
} else if self.studio_rmb_down {
self.studio_rmb_down = false;
} else if self.studio.rmb_down {
self.studio.rmb_down = false;
self.capture(false);
return Ok(());
}
@@ -10850,7 +10839,7 @@ impl Game<'_> {
if pressed
&& button == MouseButton::Left
&& self.screen == Screen::Playing
&& (self.studio_replay.is_some() || self.studio_freecam.active)
&& (self.studio.replay.is_some() || self.studio.freecam.active)
{
let size = self
.renderer
@@ -10858,8 +10847,8 @@ impl Game<'_> {
.map(|renderer| [renderer.config.width, renderer.config.height])
.unwrap_or([1, 1]);
let batch = HudBatch::new(size);
let had_open_menu = self.studio_topbar.open_menu.is_some();
let topbar_action = self.studio_topbar.handle_mouse_click(
let had_open_menu = self.studio.topbar.open_menu.is_some();
let topbar_action = self.studio.topbar.handle_mouse_click(
self.cursor,
batch.width,
batch.height,
@@ -10868,26 +10857,26 @@ impl Game<'_> {
self.handle_studio_topbar_action(topbar_action);
return Ok(());
}
if had_open_menu || self.cursor.1 <= self.studio_topbar.height {
if had_open_menu || self.cursor.1 <= self.studio.topbar.height {
return Ok(());
}
if self.studio_timeline.visible {
let action = self.studio_timeline.handle_mouse_down(
if self.studio.timeline.visible {
let action = self.studio.timeline.handle_mouse_down(
self.cursor,
batch.width,
batch.height,
&self.studio_freecam,
&self.studio.freecam,
);
if !matches!(action, horizon_client::studio::TimelineAction::None) {
self.handle_studio_timeline_action(action);
return Ok(());
}
if self.cursor.1 >= batch.height - self.studio_timeline.height() {
if self.cursor.1 >= batch.height - self.studio.timeline.height() {
return Ok(());
}
}
}
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if self.screen == Screen::Playing && self.studio.replay.is_some() {
return Ok(());
}
if matches!(self.screen, Screen::Inventory | Screen::Creative)
@@ -11357,7 +11346,7 @@ impl Game<'_> {
let between = now.saturating_duration_since(self.frame_end);
self.frame_ticks = elapsed.as_secs_f32() * 20.0;
self.previous = now;
if self.studio_freecam.active
if self.studio.freecam.active
&& self.screen == Screen::Playing
&& !self.dialog_open()
{
@@ -11367,7 +11356,7 @@ impl Game<'_> {
let vertical =
held(KeyCode::KeyE) - held(KeyCode::KeyQ) + held(KeyCode::Space);
let fast = self.keys.raw(&KeyCode::ControlRight);
self.studio_freecam.update_flight_motion(
self.studio.freecam.update_flight_motion(
forward,
strafe,
vertical,
@@ -12157,16 +12146,7 @@ impl<'a> Game<'a> {
entity_attachments: Default::default(),
remote: None,
realm_session: false,
studio_replay: None,
studio_freecam: Default::default(),
studio_timeline: Default::default(),
studio_topbar: Default::default(),
studio_rmb_down: false,
studio_camera_track_dirty: false,
studio_replay_previous_camera_type: None,
studio_replay_standalone: false,
last_replay_tick: None,
studio_virtual_tick_ms: 0.0,
studio: Default::default(),
lan: None,
integrated: None,
level_shapes: Default::default(),
+2 -2
View File
@@ -135,8 +135,8 @@ impl Game<'_> {
}
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
if self.studio_freecam.active {
return self.studio_freecam.fov.clamp(1.0, 179.0);
if self.studio.freecam.active {
return self.studio.freecam.fov.clamp(1.0, 179.0);
}
let fov = self.view.calculate_fov(
partial,
@@ -193,7 +193,7 @@ impl Game<'_> {
// replay player's hands remain visible in first person; freecam has
// no player-view hand.
if !fov::studio_draws_hand(
self.studio_freecam.active,
self.studio.freecam.active,
self.ui.hands_drawn(
self.spectator(),
self.sleeping.is_some(),
@@ -280,11 +280,11 @@ impl Game<'_> {
/// front of) it, pulled in where visual shapes are in the way. Returns the
/// position, yaw and pitch to render from.
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
if self.studio_freecam.active {
if self.studio.freecam.active {
return Some((
self.studio_freecam.position,
self.studio_freecam.yaw,
self.studio_freecam.pitch,
self.studio.freecam.position,
self.studio.freecam.yaw,
self.studio.freecam.pitch,
));
}
// Minecraft.cameraEntity: a server's spectator camera on a mob or
@@ -643,8 +643,9 @@ impl Game<'_> {
/// The model's old pitch is the player's `xRotO`, which the turn moves
/// with `xRot` (`turn_rotation`).
pub(super) fn turn_player(&mut self, xo: f64, yo: f64) {
if self.studio_freecam.active {
self.studio_freecam
if self.studio.freecam.active {
self.studio
.freecam
.turn((xo * 0.15) as f32, (yo * 0.15) as f32);
return;
}
@@ -697,7 +698,7 @@ impl Game<'_> {
/// `LevelExtractor`'s skip of the camera entity: the player's own body
/// is drawn unless the camera is in first person, or while it sleeps.
pub(super) fn own_body_in_view(&self) -> bool {
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio_freecam.active
!self.camera_type.first_person() || self.sleeping.is_some() || self.studio.freecam.active
}
/// Whether the player's own model shows in the world view (its body or,
/// while invisible, only its armour and held items).
+2 -2
View File
@@ -180,7 +180,7 @@ impl Game<'_> {
raw: KeyCode,
source: Source,
) -> Result<()> {
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if self.screen == Screen::Playing && self.studio.replay.is_some() {
if !pressed {
self.keys.release(&key, source);
return Ok(());
@@ -191,7 +191,7 @@ impl Game<'_> {
}
return Ok(());
}
if !studio_freecam_movement_key(key) || !self.studio_freecam.active {
if !studio_freecam_movement_key(key) || !self.studio.freecam.active {
return Ok(());
}
}
+6 -6
View File
@@ -10,7 +10,7 @@ impl Game<'_> {
/// the container's hotbar keys are now (`checkHotbarKeyPressed`).
pub(super) fn key_pressed(&mut self, key: KeyCode, raw: KeyCode) -> Result<()> {
self.direct_craft_stop();
if self.screen == Screen::Playing && self.studio_replay.is_some() {
if self.screen == Screen::Playing && self.studio.replay.is_some() {
match key {
KeyCode::Escape => {
self.close_studio_replay();
@@ -25,19 +25,19 @@ impl Game<'_> {
KeyCode::KeyF => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::ToggleFreecam,
),
KeyCode::KeyT => self.studio_timeline.visible = !self.studio_timeline.visible,
KeyCode::KeyT => self.studio.timeline.visible = !self.studio.timeline.visible,
KeyCode::KeyM => self.add_studio_marker(),
KeyCode::Home => self.handle_studio_topbar_action(
horizon_client::studio::TopBarAction::ResetCameraToPlayer,
),
KeyCode::KeyL => {
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
}
KeyCode::KeyK => self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::AddCameraKeyframe,
),
KeyCode::Delete => {
if let Some(index) = self.studio_timeline.selected_keyframe {
if let Some(index) = self.studio.timeline.selected_keyframe {
self.handle_studio_timeline_action(
horizon_client::studio::TimelineAction::RemoveCameraKeyframe(index),
);
@@ -78,7 +78,7 @@ impl Game<'_> {
KeyCode::Escape => match self.screen {
Screen::Options => self.options_key(KeyCode::Escape, None)?,
Screen::Playing => {
if self.studio_replay.is_some() && self.captured {
if self.studio.replay.is_some() && self.captured {
self.capture(false);
return Ok(());
}
@@ -213,7 +213,7 @@ impl Game<'_> {
// key.toggleGui is a KeyMapping: it clicks only with no screen open.
KeyCode::F1 if self.screen == Screen::Playing => self.toggle_hide_gui(),
KeyCode::F11 => self.toggle_fullscreen(),
KeyCode::F5 if self.screen == Screen::Playing && self.studio_replay.is_none() => {
KeyCode::F5 if self.screen == Screen::Playing && self.studio.replay.is_none() => {
self.cycle_camera()
}
KeyCode::KeyE
@@ -26,7 +26,7 @@ impl Game<'_> {
let pointer = self.captured || self.agent_pointer;
let grabbed = pointer
&& self.screen == Screen::Playing
&& (self.player.is_some() || self.studio_freecam.active);
&& (self.player.is_some() || self.studio.freecam.active);
let active = self.focused || self.agent_pointer;
if let Some((xo, yo)) = self
.mouse
@@ -76,8 +76,8 @@ impl Game<'_> {
&& self.control.is_none()
&& self.screen == Screen::Playing
&& !studio_suppresses_focus_pause(
self.studio_replay.is_some(),
self.studio_freecam.active,
self.studio.replay.is_some(),
self.studio.freecam.active,
)
&& self.player.is_some()
&& !self.options.no_pause
@@ -149,7 +149,7 @@ impl Game<'_> {
self.reset_inventory_clicks();
if !capture {
self.stop_mining();
self.studio_rmb_down = false;
self.studio.rmb_down = false;
}
// The real pointer takes over from an agent's.
self.agent_pointer = false;
@@ -353,9 +353,9 @@ impl Game<'_> {
/// click. A screen that is open keeps it free, as releasing the pointer
/// for that screen did: it is the screen, not the focus, that shows it.
pub(super) fn regrab_on_refocus(&mut self) {
if self.studio_replay.is_some() || self.studio_freecam.active {
if self.studio.replay.is_some() || self.studio.freecam.active {
self.regrab_on_refocus = false;
self.studio_rmb_down = false;
self.studio.rmb_down = false;
self.capture(false);
return;
}
+6 -6
View File
@@ -2433,7 +2433,7 @@ impl Game<'_> {
/// view centre, `calculateStorageRange` (the server's radius, at least
/// two, plus three) chunks each way.
pub(super) fn remote_chunk_in_range(&self, x: i32, z: i32) -> bool {
if self.studio_replay.is_some() {
if self.studio.replay.is_some() {
return true;
}
self.remote
@@ -6185,7 +6185,7 @@ impl Game<'_> {
self.apply_remote_abilities();
}
}
if self.studio_replay.is_none() {
if self.studio.replay.is_none() {
self.previous_position = position;
}
self.yaw = yaw;
@@ -6521,7 +6521,7 @@ impl Game<'_> {
return Ok(());
};
player.fluids.boat = boat;
if !riding && self.studio_replay.is_none() {
if !riding && self.studio.replay.is_none() {
self.previous_position = player.position;
}
let before = (
@@ -6728,7 +6728,7 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
// The player's own model (third person, the inventory's preview).
if self.studio_replay.is_none() {
if self.studio.replay.is_none() {
self.tick_player_model();
}
self.tick_emotes();
@@ -10528,7 +10528,7 @@ impl Game<'_> {
/// A connection's screen is over the world: connecting, reconfiguring, or
/// the level loading screen, a portal's included (`remote_overlay`).
pub(super) fn remote_screen_up(&self) -> bool {
self.studio_replay.is_none()
self.studio.replay.is_none()
&& self
.remote
.as_ref()
@@ -10612,7 +10612,7 @@ impl Game<'_> {
/// Connected or connecting with no player yet: `ConnectScreen`, then
/// `LevelLoadingScreen`, over the panorama with its blur.
pub(super) fn remote_loading(&self) -> bool {
if self.studio_replay.is_some() {
if self.studio.replay.is_some() {
return false;
}
use horizon_client::level_loading::Reason;
+162 -127
View File
@@ -15,6 +15,26 @@ use std::{
time::{Duration, SystemTime, UNIX_EPOCH},
};
/// Studio's state in a `Game`: the replay playing, its free camera, timeline and top bar. Studio is
/// a Horizon extra (docs/layers.md: the replay editor); the engine only asks it whether a replay
/// plays or the free camera flies.
#[derive(Default)]
pub(super) struct Studio {
/// The packet replay playing, if any.
pub(super) replay: Option<ReplayDriver>,
pub(super) freecam: horizon_client::studio::StudioFreecam,
pub(super) timeline: horizon_client::studio::TimelineUi,
pub(super) topbar: horizon_client::studio::TopBarUi,
pub(super) rmb_down: bool,
pub(super) camera_track_dirty: bool,
/// Camera perspective to restore after leaving replay playback.
pub(super) replay_previous_camera_type: Option<super::perspective::CameraType>,
/// Replay opened from the title screen owns the synthetic remote session.
pub(super) replay_standalone: bool,
pub(super) last_replay_tick: Option<std::time::Instant>,
pub(super) virtual_tick_ms: f32,
}
pub const HELP: &[&str] = &[
"studio record start [NAME] start recording clientbound packets into .mcpr archive",
"studio record stop stop recording and finalize .mcpr archive",
@@ -109,13 +129,13 @@ impl Game<'_> {
match words[0] {
"freecam" => match words.get(1).copied() {
Some("on") => {
if self.studio_replay.is_none() {
if self.studio.replay.is_none() {
self.system_message(Text::translate(
"horizon.studio.freecam.replay_required",
&[],
));
} else if let Some((position, yaw, pitch)) = self.camera_view(1.0) {
self.studio_freecam.activate(
self.studio.freecam.activate(
position,
yaw,
pitch,
@@ -131,24 +151,24 @@ impl Game<'_> {
}
}
Some("off") => {
self.studio_freecam.deactivate();
self.studio.freecam.deactivate();
self.capture(false);
self.system_message(Text::translate("horizon.studio.freecam.disabled", &[]));
}
_ => self.system_message(Text::translate("horizon.studio.freecam.usage", &[])),
},
"keyframe" => {
let Some(driver) = self.studio_replay.as_ref() else {
let Some(driver) = self.studio.replay.as_ref() else {
self.system_message(Text::translate("horizon.studio.replay.inactive", &[]));
return true;
};
let time_ms = driver.position_ms();
match words.get(1).copied() {
Some("add") => {
if !self.studio_freecam.active
if !self.studio.freecam.active
&& let Some((position, yaw, pitch)) = self.camera_view(1.0)
{
self.studio_freecam.activate(
self.studio.freecam.activate(
position,
yaw,
pitch,
@@ -156,16 +176,16 @@ impl Game<'_> {
);
}
self.capture(true);
self.studio_freecam.add_keyframe_at_current(time_ms);
self.studio_camera_track_dirty = true;
self.studio.freecam.add_keyframe_at_current(time_ms);
self.studio.camera_track_dirty = true;
self.system_message(Text::tr(
"horizon.studio.keyframe.added",
&[&time_ms.to_string()],
));
}
Some("clear") => {
self.studio_freecam.track.clear();
self.studio_camera_track_dirty = true;
self.studio.freecam.track.clear();
self.studio.camera_track_dirty = true;
self.system_message(Text::translate(
"horizon.studio.keyframe.cleared",
&[],
@@ -181,15 +201,15 @@ impl Game<'_> {
));
return true;
};
if index >= self.studio_freecam.track.keyframes.len() {
if index >= self.studio.freecam.track.keyframes.len() {
self.system_message(Text::translate(
"horizon.studio.keyframe.index_invalid",
&[],
));
return true;
}
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
self.system_message(Text::tr(
"horizon.studio.keyframe.removed",
&[&index.to_string()],
@@ -199,14 +219,14 @@ impl Game<'_> {
}
}
"path" => match words.get(1).copied() {
Some("lock") if !self.studio_freecam.track.is_empty() => {
self.studio_freecam.active = true;
self.studio_freecam.locked_to_path = true;
Some("lock") if !self.studio.freecam.track.is_empty() => {
self.studio.freecam.active = true;
self.studio.freecam.locked_to_path = true;
self.capture(true);
self.system_message(Text::translate("horizon.studio.path.locked", &[]));
}
Some("unlock") => {
self.studio_freecam.locked_to_path = false;
self.studio.freecam.locked_to_path = false;
self.system_message(Text::translate("horizon.studio.path.unlocked", &[]));
}
_ => self.system_message(Text::translate("horizon.studio.path.usage", &[])),
@@ -299,7 +319,7 @@ impl Game<'_> {
}
}
Some("pause") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_paused(true);
self.system_message(Text::translate("horizon.studio.replay.paused", &[]));
} else {
@@ -307,7 +327,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_paused(false);
self.system_message(Text::translate("horizon.studio.replay.resumed", &[]));
} else {
@@ -323,7 +343,7 @@ impl Game<'_> {
return true;
};
if let Ok(spd) = spd_str.parse::<f64>() {
let new_speed = if let Some(r) = self.studio_replay.as_mut() {
let new_speed = if let Some(r) = self.studio.replay.as_mut() {
r.set_speed(spd);
Some(r.speed())
} else {
@@ -357,11 +377,12 @@ impl Game<'_> {
return true;
};
if let Ok(ms) = ms_str.parse::<u32>() {
if self.studio_replay.is_some() {
if self.studio.replay.is_some() {
match self.seek_studio_replay(ms) {
Ok(()) => {
let actual_ms = self
.studio_replay
.studio
.replay
.as_ref()
.map_or(ms, |replay| ms.min(replay.duration_ms()));
let ms_text = actual_ms.to_string();
@@ -400,7 +421,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.studio_replay.as_ref() {
if let Some(r) = self.studio.replay.as_ref() {
let position = r.position_ms().to_string();
let duration = r.duration_ms().to_string();
let progress = format!("{:.1}", r.progress() * 100.0);
@@ -435,7 +456,7 @@ impl Game<'_> {
/// Reset playback tick accumulator and align positions on seek.
pub(super) fn reset_studio_playback_ticks(&mut self) {
self.studio_virtual_tick_ms = 0.0;
self.studio.virtual_tick_ms = 0.0;
if let Some(player) = &self.player {
self.previous_position = player.position;
if let Some(m) = self.player_model.as_mut() {
@@ -452,12 +473,14 @@ impl Game<'_> {
/// forward because its pre-replay world must remain intact.
pub(super) fn seek_studio_replay(&mut self, target_ms: u32) -> std::io::Result<()> {
let current_ms = self
.studio_replay
.studio
.replay
.as_ref()
.map_or(0, ReplayDriver::position_ms);
let packets = if self.studio_replay_standalone {
let packets = if self.studio.replay_standalone {
let packets = self
.studio_replay
.studio
.replay
.as_mut()
.expect("replay checked by caller")
.seek_and_collect(target_ms)?;
@@ -471,7 +494,8 @@ impl Game<'_> {
"backward seek requires a standalone replay",
));
}
self.studio_replay
self.studio
.replay
.as_mut()
.expect("replay checked by caller")
.seek_forward(target_ms)?
@@ -479,7 +503,7 @@ impl Game<'_> {
self.dispatch_studio_replay_packets(packets);
self.reset_studio_playback_ticks();
self.last_replay_tick = Some(std::time::Instant::now());
self.studio.last_replay_tick = Some(std::time::Instant::now());
Ok(())
}
@@ -510,19 +534,19 @@ impl Game<'_> {
/// Advance active replay playback by elapsed frame time and dispatch ready packets.
pub(super) fn advance_studio_replay(&mut self) -> Result<()> {
let Some(driver) = self.studio_replay.as_mut() else {
self.last_replay_tick = None;
let Some(driver) = self.studio.replay.as_mut() else {
self.studio.last_replay_tick = None;
return Ok(());
};
let now = std::time::Instant::now();
let delta = match self.last_replay_tick {
let delta = match self.studio.last_replay_tick {
Some(last) => now
.saturating_duration_since(last)
.min(Duration::from_millis(250)),
None => Duration::from_millis(16),
};
self.last_replay_tick = Some(now);
self.studio.last_replay_tick = Some(now);
let ready_packets = match driver.advance(delta) {
Ok(pkts) => pkts,
@@ -533,16 +557,18 @@ impl Game<'_> {
};
self.dispatch_studio_replay_packets(ready_packets);
if self.studio_freecam.locked_to_path
if self.studio.freecam.locked_to_path
&& let Some(position) = self
.studio_replay
.studio
.replay
.as_ref()
.map(|replay| replay.position_ms())
{
self.studio_freecam.update_spline_playback(position);
self.studio.freecam.update_spline_playback(position);
}
let paused = self
.studio_replay
.studio
.replay
.as_ref()
.is_some_and(|replay| replay.is_paused());
if self.player_model.is_none() {
@@ -550,13 +576,14 @@ impl Game<'_> {
}
if !paused {
let speed = self
.studio_replay
.studio
.replay
.as_ref()
.map_or(1.0, |r| r.speed() as f32)
.max(0.1);
self.studio_virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.studio_virtual_tick_ms >= 50.0 {
self.studio_virtual_tick_ms -= 50.0;
self.studio.virtual_tick_ms += delta.as_secs_f32() * 1000.0 * speed;
while self.studio.virtual_tick_ms >= 50.0 {
self.studio.virtual_tick_ms -= 50.0;
if let Some(player) = &self.player {
let current_pos = player.position;
let (dx, dz) = (
@@ -607,7 +634,7 @@ impl Game<'_> {
m.previous_limb_speed = m.limb_speed;
}
if let Some((position, duration, paused, markers)) =
self.studio_replay.as_ref().map(|replay| {
self.studio.replay.as_ref().map(|replay| {
(
replay.position_ms(),
replay.duration_ms(),
@@ -616,10 +643,10 @@ impl Game<'_> {
)
})
{
self.studio_timeline.current_time_ms = u64::from(position);
self.studio_timeline.duration_ms = u64::from(duration);
self.studio_timeline.playing = !paused;
self.studio_timeline.markers = markers;
self.studio.timeline.current_time_ms = u64::from(position);
self.studio.timeline.duration_ms = u64::from(duration);
self.studio.timeline.playing = !paused;
self.studio.timeline.markers = markers;
}
Ok(())
}
@@ -639,10 +666,10 @@ impl Game<'_> {
self.capture(false);
}
TopBarAction::ExitStudio => {
if self.studio_replay.is_some() {
if self.studio.replay.is_some() {
self.close_studio_replay();
} else {
self.studio_freecam.deactivate();
self.studio.freecam.deactivate();
self.capture(false);
}
}
@@ -668,42 +695,46 @@ impl Game<'_> {
} else {
(glam::DVec3::ZERO, 0.0, 0.0)
};
self.studio_freecam
self.studio
.freecam
.activate(position, yaw, pitch, self.settings.fov);
}
TopBarAction::ToggleLockToPath => {
self.studio_freecam.locked_to_path = !self.studio_freecam.locked_to_path;
self.studio.freecam.locked_to_path = !self.studio.freecam.locked_to_path;
}
TopBarAction::ToggleFreecam => {
if self.studio_freecam.active {
self.studio_freecam.deactivate();
if self.studio.freecam.active {
self.studio.freecam.deactivate();
self.capture(false);
} else {
let (position, yaw, pitch) =
self.camera_view(0.0)
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
self.studio_freecam
self.studio
.freecam
.activate(position, yaw, pitch, self.settings.fov);
self.capture(true);
}
}
TopBarAction::AddKeyframe => {
self.studio_freecam
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
self.studio_camera_track_dirty = true;
self.studio
.freecam
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
self.studio.camera_track_dirty = true;
}
TopBarAction::AddMarker => self.add_studio_marker(),
TopBarAction::ClearKeyframes => {
self.studio_freecam.track.keyframes.clear();
self.studio_timeline.selected_keyframe = None;
self.studio_camera_track_dirty = true;
self.studio.freecam.track.keyframes.clear();
self.studio.timeline.selected_keyframe = None;
self.studio.camera_track_dirty = true;
}
TopBarAction::ToggleTimeline => {
self.studio_timeline.visible = !self.studio_timeline.visible;
self.studio.timeline.visible = !self.studio.timeline.visible;
}
TopBarAction::SplitClip => {
self.studio_timeline
.split_at(self.studio_timeline.current_time_ms);
self.studio
.timeline
.split_at(self.studio.timeline.current_time_ms);
}
}
}
@@ -716,93 +747,97 @@ impl Game<'_> {
use horizon_client::studio::TimelineAction;
match action {
TimelineAction::PauseForSeek => {
self.studio_timeline.playing = false;
if let Some(replay) = self.studio_replay.as_mut() {
self.studio.timeline.playing = false;
if let Some(replay) = self.studio.replay.as_mut() {
replay.set_paused(true);
}
}
TimelineAction::Seek(time_ms) => {
let time_ms = if self.studio_replay_standalone {
let time_ms = if self.studio.replay_standalone {
time_ms
} else {
let current =
self.studio_replay
self.studio
.replay
.as_ref()
.map_or(0, ReplayDriver::position_ms) as u64;
time_ms.max(current)
};
self.studio_timeline.current_time_ms = time_ms;
self.studio.timeline.current_time_ms = time_ms;
if let Err(error) = self.seek_studio_replay(time_ms.min(u32::MAX as u64) as u32) {
log::warn!("Studio timeline seek failed: {error}");
}
}
TimelineAction::TogglePlay => {
self.studio_timeline.playing = !self.studio_timeline.playing;
if let Some(replay) = self.studio_replay.as_mut() {
replay.set_paused(!self.studio_timeline.playing);
self.studio.timeline.playing = !self.studio.timeline.playing;
if let Some(replay) = self.studio.replay.as_mut() {
replay.set_paused(!self.studio.timeline.playing);
}
}
TimelineAction::StepBack => {
let time_ms = self.studio_timeline.current_time_ms.saturating_sub(1000);
let time_ms = self.studio.timeline.current_time_ms.saturating_sub(1000);
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
}
TimelineAction::StepForward => {
let time_ms = self
.studio_timeline
.studio
.timeline
.current_time_ms
.saturating_add(1000)
.min(self.studio_timeline.duration_ms);
.min(self.studio.timeline.duration_ms);
self.handle_studio_timeline_action(TimelineAction::Seek(time_ms));
}
TimelineAction::SeekStart => {
self.handle_studio_timeline_action(TimelineAction::Seek(0))
}
TimelineAction::SeekEnd => self.handle_studio_timeline_action(TimelineAction::Seek(
self.studio_timeline.duration_ms,
self.studio.timeline.duration_ms,
)),
TimelineAction::SetSpeed(speed) => {
self.studio_timeline.speed = speed;
if let Some(replay) = self.studio_replay.as_mut() {
self.studio.timeline.speed = speed;
if let Some(replay) = self.studio.replay.as_mut() {
replay.set_speed(speed);
}
}
TimelineAction::ToggleFreecam => {
if self.studio_freecam.active {
self.studio_freecam.deactivate();
if self.studio.freecam.active {
self.studio.freecam.deactivate();
} else {
let (position, yaw, pitch) =
self.camera_view(0.0)
.unwrap_or((glam::DVec3::ZERO, 0.0, 0.0));
self.studio_freecam
self.studio
.freecam
.activate(position, yaw, pitch, self.settings.fov);
}
}
TimelineAction::AddCameraKeyframe => {
self.studio_freecam
.add_keyframe_at_current(self.studio_timeline.current_time_ms as u32);
self.studio_camera_track_dirty = true;
self.studio
.freecam
.add_keyframe_at_current(self.studio.timeline.current_time_ms as u32);
self.studio.camera_track_dirty = true;
}
TimelineAction::RemoveCameraKeyframe(index) => {
if index < self.studio_freecam.track.keyframes.len() {
self.studio_freecam.track.keyframes.remove(index);
self.studio_camera_track_dirty = true;
if index < self.studio.freecam.track.keyframes.len() {
self.studio.freecam.track.keyframes.remove(index);
self.studio.camera_track_dirty = true;
}
self.studio_timeline.selected_keyframe = None;
self.studio.timeline.selected_keyframe = None;
}
TimelineAction::SelectKeyframe(index) => {
self.studio_timeline.selected_keyframe = Some(index);
self.studio.timeline.selected_keyframe = Some(index);
}
TimelineAction::KeyframeMoved => {
self.studio_camera_track_dirty = true;
self.studio.camera_track_dirty = true;
}
TimelineAction::SplitClip(time_ms) => self.studio_timeline.split_at(time_ms),
TimelineAction::SplitClip(time_ms) => self.studio.timeline.split_at(time_ms),
TimelineAction::None => {}
}
}
/// Add a bookmark at the replay playhead and save it in the `.mcpr` archive.
pub(super) fn add_studio_marker(&mut self) {
let Some(replay) = self.studio_replay.as_mut() else {
let Some(replay) = self.studio.replay.as_mut() else {
return;
};
let label = Text::translate(
@@ -811,7 +846,7 @@ impl Game<'_> {
)
.plain();
match replay.add_marker(label) {
Ok(_) => self.studio_timeline.markers = replay.markers().to_vec(),
Ok(_) => self.studio.timeline.markers = replay.markers().to_vec(),
Err(error) => log::warn!("Studio: could not save replay marker: {error}"),
}
}
@@ -845,7 +880,7 @@ impl Game<'_> {
Ok(Some(res))
}
Some("pause") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_paused(true);
Ok(Some(json!({ "ok": true, "paused": true })))
} else {
@@ -853,7 +888,7 @@ impl Game<'_> {
}
}
Some("resume") => {
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_paused(false);
Ok(Some(json!({ "ok": true, "paused": false })))
} else {
@@ -863,7 +898,7 @@ impl Game<'_> {
Some("speed") => {
let spd_str = words.get(3).ok_or("missing speed value")?;
let spd: f64 = spd_str.parse().map_err(|_| "invalid speed")?;
if let Some(r) = self.studio_replay.as_mut() {
if let Some(r) = self.studio.replay.as_mut() {
r.set_speed(spd);
Ok(Some(json!({ "ok": true, "speed": r.speed() })))
} else {
@@ -873,7 +908,7 @@ impl Game<'_> {
Some("seek") => {
let ms_str = words.get(3).ok_or("missing seek timestamp")?;
let ms: u32 = ms_str.parse().map_err(|_| "invalid timestamp")?;
let pos = if let Some(r) = self.studio_replay.as_mut() {
let pos = if let Some(r) = self.studio.replay.as_mut() {
r.seek(ms).map_err(|e| format!("{e}"))?;
Some(r.position_ms())
} else {
@@ -887,7 +922,7 @@ impl Game<'_> {
}
}
Some("status") => {
if let Some(r) = self.studio_replay.as_ref() {
if let Some(r) = self.studio.replay.as_ref() {
Ok(Some(json!({
"ok": true,
"active": true,
@@ -999,13 +1034,13 @@ impl Game<'_> {
}
pub(super) fn open_studio_replay(&mut self, path: &str) -> Result<Value> {
if self.studio_camera_track_dirty
&& let Some(active) = self.studio_replay.as_mut()
if self.studio.camera_track_dirty
&& let Some(active) = self.studio.replay.as_mut()
{
if let Err(error) = active.save_camera_track(&self.studio_freecam.track) {
if let Err(error) = active.save_camera_track(&self.studio.freecam.track) {
return Ok(json!({ "ok": false, "error": error.to_string() }));
}
self.studio_camera_track_dirty = false;
self.studio.camera_track_dirty = false;
}
// The recordings browser can be opened while the title screen is
// available but the slower world tables are still loading. Replay
@@ -1028,7 +1063,7 @@ impl Game<'_> {
}
};
let standalone = self.studio_replay.is_none() && self.remote.is_none();
let standalone = self.studio.replay.is_none() && self.remote.is_none();
if self.remote.is_none()
&& let Err(error) = self.init_remote_for_replay()
{
@@ -1038,17 +1073,17 @@ impl Game<'_> {
let duration_ms = driver.duration_ms();
let packets = driver.packet_count();
self.studio_timeline.duration_ms = u64::from(duration_ms);
self.studio_timeline.current_time_ms = 0;
self.studio_timeline.playing = !driver.is_paused();
self.studio_timeline.markers = driver.markers().to_vec();
self.studio_freecam.track = driver.camera_track().cloned().unwrap_or_default();
self.studio_camera_track_dirty = false;
if self.studio_replay.is_none() {
self.studio_replay_previous_camera_type = Some(self.camera_type);
self.studio_replay_standalone = standalone;
self.studio.timeline.duration_ms = u64::from(duration_ms);
self.studio.timeline.current_time_ms = 0;
self.studio.timeline.playing = !driver.is_paused();
self.studio.timeline.markers = driver.markers().to_vec();
self.studio.freecam.track = driver.camera_track().cloned().unwrap_or_default();
self.studio.camera_track_dirty = false;
if self.studio.replay.is_none() {
self.studio.replay_previous_camera_type = Some(self.camera_type);
self.studio.replay_standalone = standalone;
}
self.studio_replay = Some(driver);
self.studio.replay = Some(driver);
self.camera_type = super::perspective::CameraType::FirstPerson;
self.title = None;
self.screen = Screen::Playing;
@@ -1066,14 +1101,14 @@ impl Game<'_> {
}))
}
pub(super) fn close_studio_replay(&mut self) -> bool {
if self.studio_camera_track_dirty
&& let Some(driver) = self.studio_replay.as_mut()
&& let Err(error) = driver.save_camera_track(&self.studio_freecam.track)
if self.studio.camera_track_dirty
&& let Some(driver) = self.studio.replay.as_mut()
&& let Err(error) = driver.save_camera_track(&self.studio.freecam.track)
{
log::warn!("Studio: could not save camera path: {error}");
}
self.studio_camera_track_dirty = false;
let closed = self.studio_replay.take().is_some();
self.studio.camera_track_dirty = false;
let closed = self.studio.replay.take().is_some();
if !closed {
return false;
}
@@ -1081,23 +1116,23 @@ impl Game<'_> {
// A standalone replay owns a synthetic Remote connection. Treat that
// connection as replay state even if the saved origin flag was lost;
// sending it through leave_server() opens the Multiplayer screen.
let standalone = self.studio_replay_standalone
let standalone = self.studio.replay_standalone
|| self
.remote
.as_ref()
.is_some_and(|remote| remote.brand == "horizon-studio");
self.studio_replay_standalone = false;
if let Some(camera_type) = self.studio_replay_previous_camera_type.take() {
self.studio.replay_standalone = false;
if let Some(camera_type) = self.studio.replay_previous_camera_type.take() {
self.camera_type = camera_type;
}
self.last_replay_tick = None;
self.studio_timeline.current_time_ms = 0;
self.studio_timeline.playing = false;
self.studio_timeline.markers.clear();
self.studio_timeline.split_points.clear();
self.studio_timeline.selected_keyframe = None;
self.studio_freecam.deactivate();
self.studio_virtual_tick_ms = 0.0;
self.studio.last_replay_tick = None;
self.studio.timeline.current_time_ms = 0;
self.studio.timeline.playing = false;
self.studio.timeline.markers.clear();
self.studio.timeline.split_points.clear();
self.studio.timeline.selected_keyframe = None;
self.studio.freecam.deactivate();
self.studio.virtual_tick_ms = 0.0;
self.capture(false);
if standalone {
if let Some(mut remote) = self.remote.take() {
@@ -245,7 +245,7 @@ impl Game<'_> {
// Pause-menu and death-screen exits use the normal world-close action.
// A standalone replay owns a synthetic remote session, so it must go
// through replay cleanup instead of being treated like a server.
if self.studio_replay.is_some() {
if self.studio.replay.is_some() {
self.close_studio_replay();
return Ok(());
}
+4 -3
View File
@@ -26,7 +26,8 @@ impl View {
}
struct Settings { fov: f32 }
struct StudioFreecam { active: bool, fov: f32 }
struct Game { view: View, settings: Settings, override_fov: Option<f32>, studio_freecam: StudioFreecam }
struct Studio { freecam: StudioFreecam }
struct Game { view: View, settings: Settings, override_fov: Option<f32>, studio: Studio }
impl Game {
fn fov_effect_scale(&self) -> f32 { 1.0 }
fn death_time(&self) -> Option<i32> { None }
@@ -44,14 +45,14 @@ fn unchanged_narrow_wide_and_invalid() {
(Some(1.0), 1.0), (Some(179.0), 179.0), (Some(0.0), 35.0), (Some(180.0), 35.0),
(Some(-1.0), 35.0), (Some(f32::NAN), 35.0), (Some(f32::INFINITY), 35.0)] {
let game = Game { view: View, settings: Settings { fov: 70.0 }, override_fov: value,
studio_freecam: StudioFreecam { active: false, fov: 70.0 } };
studio: Studio { freecam: StudioFreecam { active: false, fov: 70.0 } } };
assert_eq!(game.fov_degrees(0.25, [1.0, 2.0, 3.0]), expected);
}
}
#[test]
fn studio_freecam_uses_its_fov() {
let game = Game { view: View, settings: Settings { fov: 70.0 }, override_fov: None,
studio_freecam: StudioFreecam { active: true, fov: 42.0 } };
studio: Studio { freecam: StudioFreecam { active: true, fov: 42.0 } } };
assert_eq!(game.fov_degrees(0.25, [1.0, 2.0, 3.0]), 42.0);
}
""".replace("METHOD", actual)
+7 -6
View File
@@ -119,6 +119,8 @@ struct Remote;
#[derive(Default)]
struct StudioFreecam { active: bool }
#[derive(Default)]
struct Studio { replay: Option<()>, freecam: StudioFreecam, rmb_down: bool }
#[derive(Default)]
struct Options { offscreen: bool }
#[derive(Default)]
struct ServerPacks { prompt: Option<()> }
@@ -127,7 +129,7 @@ struct Game {
keyboard_mouse: bool, split_window: bool, view_at: [u32; 2],
captured: bool, capture_mode: Option<CursorGrabMode>, regrab_on_refocus: bool, title: Option<()>, screen: Screen,
remote: Option<Remote>,
studio_replay: Option<()>, studio_freecam: StudioFreecam, studio_rmb_down: bool,
studio: Studio,
mining: bool, lan_acting: Option<u32>, renderer: Option<Renderer>, cursor: (f32, f32),
server_packs: ServerPacks, command_prompt: Option<()>, options: Options,
unclaimed_window: bool, scripted: bool, focus_after_run: Option<bool>, applied_focus: Vec<bool>,
@@ -137,8 +139,7 @@ impl Default for Game {
Self { focused: true, agent_pointer: false, window: Some(Window::default()),
keyboard_mouse: true, split_window: false, view_at: [0, 0],
mouse: Mouse::default(), captured: false, capture_mode: None, regrab_on_refocus: false, title: None,
screen: Screen::Playing, remote: None, studio_replay: None,
studio_freecam: StudioFreecam::default(), studio_rmb_down: false,
screen: Screen::Playing, remote: None, studio: Studio::default(),
mining: false, lan_acting: None,
renderer: None, cursor: (0.0, 0.0), server_packs: ServerPacks::default(),
command_prompt: None, options: Options::default(),
@@ -273,8 +274,8 @@ fn main() {
// only captures it while the right mouse button is held.
{
let mut game = Game::default();
game.studio_replay = Some(());
game.studio_rmb_down = true;
game.studio.replay = Some(());
game.studio.rmb_down = true;
game.capture(true);
assert!(game.captured);
game.regrab_on_refocus = true;
@@ -283,7 +284,7 @@ fn main() {
game.focused = true;
game.regrab_on_refocus();
assert!(!game.captured);
assert!(!game.studio_rmb_down);
assert!(!game.studio.rmb_down);
}
// A controller-only player's screens leave the shared native pointer alone.
{