mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge pull request #9812 from rison1337/native-hook-stamp-selected
🤖 Agent: `--only` keeps first-parent order and leaves the other 18 fragments and their markers byte-identical. The pending signatures in #9431 and #9649 belong to other fragments, so stamping 410/411 doesn't settle them early. If another stamp lands first, regenerate.
This commit is contained in:
8 files changed
+225
-121
No files matched your search
@@ -6,6 +6,10 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 411 adds client Host services, GUI painting, model-head snapshots and client lifecycle notifications.
|
||||
//!
|
||||
//! API 410 adds a final foreground GUI overlay (`native_gui_render_post`) and the current frame's camera during GUI rendering.
|
||||
//!
|
||||
//! API 409 adds a client mod's play packet to the server, as it is (`native_send_packet`).
|
||||
//!
|
||||
//! API 408 adds reading the server's play packets on the client (`native_client_packet`).
|
||||
@@ -1160,7 +1164,7 @@ pub(super) use horizon_world::level::flags;
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 409;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 411;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. 232.
|
||||
@@ -2510,7 +2514,7 @@ impl Game<'_> {
|
||||
/// is in GUI pixels. `native_camera` is this frame's world camera (None on
|
||||
/// the panorama). `gui_hidden` is the F1 setting; `focused` is window focus.
|
||||
/// A mod decides whether its foreground indicator respects those flags.
|
||||
/// Client only; the host is read-only. API @API:gui_render_post@.
|
||||
/// Client only; the host is read-only. API 410.
|
||||
pub(super) fn native_gui_render_post(
|
||||
&self,
|
||||
batch: &mut HudBatch,
|
||||
|
||||
@@ -1704,6 +1704,10 @@ API 408 adds reading the server's play packets on the client (`native_client_pac
|
||||
|
||||
API 409 adds `native_send_packet(packet)` sends any serverbound play packet (`ServerboundPlayPacket`), a plugin channel's custom payload included, to any server, unchecked; an error when not in a server's play state. Bundles need "hooks": 409.
|
||||
|
||||
API 410 adds `native_gui_render_post(batch, gui_hidden, focused)` draws after the complete normal GUI batch: screens, prompts, toasts, debug overlays, pad cursor and IME included. It fires during gameplay, menus and the title, even with F1; loading/file-upgrade and resource-reload overlays do not fire it. The read-only host's `camera()` is the current frame's world camera (None on the panorama), including bob/spin, FOV and projection. `batch` uses GUI pixels; `gui_hidden` reports F1 and `focused` reports window focus so the mod can choose when its indicator is visible. The existing `hud` and `screen_render_post` hooks retain their drawing order and visibility rules. Bundles need "hooks": 410.
|
||||
|
||||
API 411 adds Client-only Host snapshots of the window, connection, entities, interpolated model heads and profile skins; visual rays and block occlusion facts; atlas GUI painting, nine-slice widgets and tooltips; persisted mod key changes and a local action bar. client_start runs before connecting (a window need not exist yet), client_disconnecting before a real connection is discarded (not dimension travel), client_stopping once before final save/exit. menu_background can change a normal menu blur request (None disables it; finite 1..64 only). Bundles need "hooks": 411.
|
||||
|
||||
## What the launcher does
|
||||
|
||||
**Developing a mod?** Run `horizon-launcher settings native-profile dev` once, or pick "Dev build" in the Mods page's
|
||||
|
||||
@@ -673,6 +673,66 @@
|
||||
{
|
||||
"name": "send_packet",
|
||||
"target": "horizon_client::game::native_net::Game<'_>::native_send_packet"
|
||||
},
|
||||
{
|
||||
"name": "client_state",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_state"
|
||||
},
|
||||
{
|
||||
"name": "client_entities",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_entities"
|
||||
},
|
||||
{
|
||||
"name": "client_heads",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_heads"
|
||||
},
|
||||
{
|
||||
"name": "client_profiles",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_profiles"
|
||||
},
|
||||
{
|
||||
"name": "client_visual_clear",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_visual_clear"
|
||||
},
|
||||
{
|
||||
"name": "client_block_obstruction",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_block_obstruction"
|
||||
},
|
||||
{
|
||||
"name": "gui_paint",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_gui_paint"
|
||||
},
|
||||
{
|
||||
"name": "gui_nine_slice",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_gui_nine_slice"
|
||||
},
|
||||
{
|
||||
"name": "gui_tooltip",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_gui_tooltip"
|
||||
},
|
||||
{
|
||||
"name": "set_mod_key",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_set_mod_key"
|
||||
},
|
||||
{
|
||||
"name": "client_action_bar",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_action_bar"
|
||||
},
|
||||
{
|
||||
"name": "text_width",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_text_width"
|
||||
},
|
||||
{
|
||||
"name": "playing",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_playing"
|
||||
},
|
||||
{
|
||||
"name": "window",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_window"
|
||||
},
|
||||
{
|
||||
"name": "player",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_player"
|
||||
}
|
||||
],
|
||||
"hooks": [
|
||||
@@ -4974,6 +5034,52 @@
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "client_start",
|
||||
"version": 1,
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_start",
|
||||
"side": "client",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "client_disconnecting",
|
||||
"version": 1,
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_disconnecting",
|
||||
"side": "client",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "client_stopping",
|
||||
"version": 1,
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_stopping",
|
||||
"side": "client",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "menu_background",
|
||||
"version": 1,
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_menu_background",
|
||||
"side": "client",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "gui_render_post",
|
||||
"version": 1,
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_gui_render_post",
|
||||
"side": "client",
|
||||
"cancellable": false,
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SPEC = importlib.util.spec_from_file_location(
|
||||
"stamp_native_hooks", Path(__file__).resolve().parents[1] / "tools/stamp_native_hooks.py"
|
||||
@@ -88,6 +89,84 @@ class StampTests(unittest.TestCase):
|
||||
self.assertEqual(json.loads((self.root / stamp.CATALOG).read_text())["hooks"], [])
|
||||
self.assertEqual(json.loads((self.root / stamp.CATALOG).read_text())["helpers"], fragment["helpers"])
|
||||
|
||||
def test_selected_fragments_keep_merge_order_and_others_stay_pending(self):
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-qm", "base")
|
||||
# An older, unfinished API must not stop two later, reviewed APIs being registered.
|
||||
for name in ("blocked", "z", "a"):
|
||||
self.fragment(name)
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-qm", name)
|
||||
blocked = self.root / stamp.FRAGMENTS / "blocked.json"
|
||||
pending_comment = self.root / "pending.rs"
|
||||
pending_comment.write_text("/// Since API @API:blocked@.\n")
|
||||
fragment = json.loads(blocked.read_text())
|
||||
fragment["comments"] = ["pending.rs"]
|
||||
blocked.write_text(json.dumps(fragment))
|
||||
api = self.root / stamp.MOD_API
|
||||
api.write_text("/// APIs @API:blocked@, @API:z@ and @API:a@.\n")
|
||||
before = {path: path.read_bytes() for path in (blocked, pending_comment)}
|
||||
|
||||
# CLI order must not override merge order; duplicate selections must not consume numbers.
|
||||
self.assertFalse(stamp.stamp(False, ["a", "z", "a"]))
|
||||
source = (self.root / stamp.SOURCE).read_text()
|
||||
self.assertIn("API 8 adds z.", source)
|
||||
self.assertIn("API 9 adds a.", source)
|
||||
self.assertIn("NATIVE_HOOKS_API: u32 = 9;", source)
|
||||
self.assertEqual(api.read_text(), "/// APIs @API:blocked@, 8 and 9.\n")
|
||||
docs = (self.root / stamp.DOCS).read_text()
|
||||
self.assertIn("API 8 adds z; requires 8.", docs)
|
||||
self.assertIn("API 9 adds a; requires 9.", docs)
|
||||
self.assertEqual({path: path.read_bytes() for path in before}, before)
|
||||
self.assertEqual(sorted(path.stem for path in blocked.parent.glob("*.json")), ["blocked"])
|
||||
hooks = json.loads((self.root / stamp.CATALOG).read_text())["hooks"]
|
||||
self.assertEqual([h["name"] for h in hooks], ["a", "z"])
|
||||
|
||||
# Once ready, the skipped API gets the next free number, even though it merged first.
|
||||
self.assertTrue(stamp.stamp(True))
|
||||
self.assertFalse(stamp.stamp(False))
|
||||
self.assertIn("API 10 adds blocked.", (self.root / stamp.SOURCE).read_text())
|
||||
self.assertIn("API 10 adds blocked; requires 10.", (self.root / stamp.DOCS).read_text())
|
||||
self.assertEqual(api.read_text(), "/// APIs 10, 8 and 9.\n")
|
||||
self.assertEqual(pending_comment.read_text(), "/// Since API 10.\n")
|
||||
hooks = json.loads((self.root / stamp.CATALOG).read_text())["hooks"]
|
||||
self.assertEqual([h["name"] for h in hooks], ["a", "z", "blocked"])
|
||||
self.assertFalse(stamp.stamp(False))
|
||||
|
||||
def test_selection_rejects_unknown_names_before_writing_even_in_check_mode(self):
|
||||
path = self.fragment("ready")
|
||||
before = {p: p.read_bytes() for p in self.root.rglob("*") if p.is_file()}
|
||||
for check in (False, True):
|
||||
with self.subTest(check=check):
|
||||
with self.assertRaisesRegex(ValueError, "unknown pending fragment.*typo"):
|
||||
stamp.stamp(check, ["ready", "typo"])
|
||||
self.assertEqual({p: p.read_bytes() for p in before}, before)
|
||||
path.unlink()
|
||||
with self.assertRaisesRegex(ValueError, "unknown pending fragment.*ready"):
|
||||
stamp.stamp(False, ["ready"])
|
||||
|
||||
def test_selection_cli_check_is_read_only_and_can_stamp_helper_only(self):
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-qm", "base")
|
||||
path = self.fragment("ready")
|
||||
fragment = json.loads(path.read_text())
|
||||
fragment["hooks"] = []
|
||||
fragment["helpers"] = [{"name": "helper", "target": "target::helper"}]
|
||||
path.write_text(json.dumps(fragment))
|
||||
blocked = self.fragment("blocked")
|
||||
before = {p: p.read_bytes() for p in self.root.rglob("*") if p.is_file()}
|
||||
with patch("sys.argv", ["stamp_native_hooks.py", "--check", "--only", "ready"]):
|
||||
self.assertEqual(stamp.main(), 1)
|
||||
self.assertEqual({p: p.read_bytes() for p in before}, before)
|
||||
with patch("sys.argv", ["stamp_native_hooks.py", "--only", "ready"]):
|
||||
self.assertEqual(stamp.main(), 0)
|
||||
catalog = json.loads((self.root / stamp.CATALOG).read_text())
|
||||
self.assertEqual(catalog["hooks"], [])
|
||||
self.assertEqual(catalog["helpers"], fragment["helpers"])
|
||||
self.assertIn("NATIVE_HOOKS_API: u32 = 8;", (self.root / stamp.SOURCE).read_text())
|
||||
self.assertFalse(path.exists())
|
||||
self.assertEqual(blocked.read_bytes(), before[blocked])
|
||||
|
||||
def test_catalog_facts_travel_with_the_hook(self):
|
||||
self.git("add", ".")
|
||||
self.git("commit", "-qm", "base")
|
||||
|
||||
@@ -26,7 +26,23 @@ not API yet; do not depend on it until it has a number.
|
||||
|
||||
After hook PRs merge, run `python3 tools/stamp_native_hooks.py` on current
|
||||
main and open a PR with the result. The tool assigns one API number per
|
||||
fragment in first-parent merge order. It consumes all pending fragments.
|
||||
`--check` exits nonzero when any are pending. Running the tool again does
|
||||
nothing. For local testing only, run it on a hook branch without committing
|
||||
fragment in first-parent merge order. By default it consumes all pending
|
||||
fragments; running it again does nothing. `--check` exits nonzero when any
|
||||
are pending.
|
||||
|
||||
To register reviewed fragments while unrelated contracts are still being
|
||||
settled, select their names without `.json`:
|
||||
|
||||
```sh
|
||||
python3 tools/stamp_native_hooks.py --only gui_render_post client_services
|
||||
```
|
||||
|
||||
Only the selected fragments get numbers, still in merge order regardless of
|
||||
argument order. All others and their markers remain pending; a later stamp
|
||||
continues from the current API number. Missing names (including already
|
||||
stamped ones) fail before writing anything. `--check --only ...` checks the
|
||||
selection without changing files. Selection does not check dependencies:
|
||||
include any pending contracts the selected APIs require.
|
||||
|
||||
For local testing only, run the tool on a hook branch without committing
|
||||
the result. If another stamp PR merges first, regenerate from current main.
|
||||
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "client_start",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_start",
|
||||
"side": "client",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "client_disconnecting",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_disconnecting",
|
||||
"side": "client",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "client_stopping",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_client_stopping",
|
||||
"side": "client",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "menu_background",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_menu_background",
|
||||
"side": "client",
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
}
|
||||
],
|
||||
"helpers": [
|
||||
{
|
||||
"name": "client_state",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_state"
|
||||
},
|
||||
{
|
||||
"name": "client_entities",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_entities"
|
||||
},
|
||||
{
|
||||
"name": "client_heads",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_heads"
|
||||
},
|
||||
{
|
||||
"name": "client_profiles",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_profiles"
|
||||
},
|
||||
{
|
||||
"name": "client_visual_clear",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_visual_clear"
|
||||
},
|
||||
{
|
||||
"name": "client_block_obstruction",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_block_obstruction"
|
||||
},
|
||||
{
|
||||
"name": "gui_paint",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_gui_paint"
|
||||
},
|
||||
{
|
||||
"name": "gui_nine_slice",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_gui_nine_slice"
|
||||
},
|
||||
{
|
||||
"name": "gui_tooltip",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_gui_tooltip"
|
||||
},
|
||||
{
|
||||
"name": "set_mod_key",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_set_mod_key"
|
||||
},
|
||||
{
|
||||
"name": "client_action_bar",
|
||||
"target": "horizon_client::game::native_client_services::Game<'_>::native_client_action_bar"
|
||||
},
|
||||
{
|
||||
"name": "text_width",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_text_width"
|
||||
},
|
||||
{
|
||||
"name": "playing",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_playing"
|
||||
},
|
||||
{
|
||||
"name": "window",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_window"
|
||||
},
|
||||
{
|
||||
"name": "player",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_player"
|
||||
}
|
||||
],
|
||||
"rust_summary": "client Host services, GUI painting, model-head snapshots and client lifecycle notifications.",
|
||||
"doc_summary": "Client-only Host snapshots of the window, connection, entities, interpolated model heads and profile skins; visual rays and block occlusion facts; atlas GUI painting, nine-slice widgets and tooltips; persisted mod key changes and a local action bar. client_start runs before connecting (a window need not exist yet), client_disconnecting before a real connection is discarded (not dimension travel), client_stopping once before final save/exit. menu_background can change a normal menu blur request (None disables it; finite 1..64 only). Bundles need \"hooks\": @API:client_services@."
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "gui_render_post",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_gui_render_post",
|
||||
"mod_api": {"contract": "listen"},
|
||||
"side": "client",
|
||||
"cancellable": false
|
||||
}
|
||||
],
|
||||
"rust_summary": "a final foreground GUI overlay (`native_gui_render_post`) and the current frame's camera during GUI rendering.",
|
||||
"doc_summary": "`native_gui_render_post(batch, gui_hidden, focused)` draws after the complete normal GUI batch: screens, prompts, toasts, debug overlays, pad cursor and IME included. It fires during gameplay, menus and the title, even with F1; loading/file-upgrade and resource-reload overlays do not fire it. The read-only host's `camera()` is the current frame's world camera (None on the panorama), including bob/spin, FOV and projection. `batch` uses GUI pixels; `gui_hidden` reports F1 and `focused` reports window focus so the mod can choose when its indicator is visible. The existing `hud` and `screen_render_post` hooks retain their drawing order and visibility rules. Bundles need \"hooks\": @API:gui_render_post@."
|
||||
}
|
||||
@@ -16,8 +16,14 @@ CATALOG = Path("native-hooks.json")
|
||||
MOD_API = Path("crates/horizon-client/src/mod_api.rs")
|
||||
|
||||
|
||||
def stamp(check):
|
||||
def stamp(check, only=None):
|
||||
paths = sorted((ROOT / FRAGMENTS).glob("*.json"))
|
||||
if only is not None:
|
||||
selected = set(only)
|
||||
missing = selected - {path.stem for path in paths}
|
||||
if missing:
|
||||
raise ValueError(f"unknown pending fragment(s): {', '.join(sorted(missing))}")
|
||||
paths = [path for path in paths if path.stem in selected]
|
||||
if not paths:
|
||||
return False
|
||||
if check:
|
||||
@@ -45,7 +51,7 @@ def stamp(check):
|
||||
names = {hook["name"] for hook in catalog["hooks"]}
|
||||
helper_names = {helper["name"] for helper in catalog.get("helpers", [])}
|
||||
extra = {}
|
||||
# Match the build-time generator's fragment order so stamping preserves the generated API.
|
||||
# Append the selected entries in filename order, as the build-time generator reads fragments.
|
||||
for path in sorted(ordered):
|
||||
fragment = json.loads(path.read_text())
|
||||
for hook in fragment["hooks"]:
|
||||
@@ -109,9 +115,11 @@ def stamp(check):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--check", action="store_true", help="fail if fragments are pending")
|
||||
parser.add_argument("--only", nargs="+", metavar="FRAGMENT",
|
||||
help="stamp only these pending fragment names (without .json), in merge order")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
return int(stamp(args.check))
|
||||
return int(stamp(args.check, args.only))
|
||||
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
||||
print(f"stamp_native_hooks: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
Reference in new issue
Block a user