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:
Ludovic Marie authored and GitHub committed 2026-11-05 15:32:42 +01:00
commit d52ff7a6f7
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,
+4
View File
@@ -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
+106
View File
@@ -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"
}
}
]
}
+79
View File
@@ -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")
+19 -3
View File
@@ -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@."
}
+11 -3
View File
@@ -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