diff --git a/framework/include/mhfu/events.h b/framework/include/mhfu/events.h index a0b26e6c..960de2a7 100644 --- a/framework/include/mhfu/events.h +++ b/framework/include/mhfu/events.h @@ -19,7 +19,7 @@ typedef enum { MHFU_EVENT_QUEST_BEGINNING, /* the player commits to a quest */ MHFU_EVENT_QUEST_ENTERED, /* the first area after QUEST_BEGINNING */ MHFU_EVENT_MAP_SECTION_ENTERED, /* the area index changed */ - MHFU_EVENT_MONSTER_SPAWNED, /* a new entity in registry slot 1 or above */ + MHFU_EVENT_MONSTER_SPAWNED, /* a new entity in registry slot 1 or above; each quest reports its own */ MHFU_EVENT_QUEST_TARGETS_BUILDING, /* edit the quest's monster list here (quest.h) */ /* big monsters, ai.h */ MHFU_EVENT_AI_OVERLAY_LOADED, diff --git a/framework/src/core/em_vhook.cpp b/framework/src/core/em_vhook.cpp index 36642d6e..64a5dd24 100644 --- a/framework/src/core/em_vhook.cpp +++ b/framework/src/core/em_vhook.cpp @@ -94,14 +94,15 @@ * build_act_stub). * * A slot's code is valid only while that species' overlay is resident, so we latch - * onto the vtable of a big monster the engine spawned and restore on quest exit, - * dropping every rule, substitution and request with it. One config block per + * onto the vtable of a big monster of the quest the engine spawned (latches()) and restore + * on quest exit, dropping every rule, substitution and request with it. One config block per * latched vtable, not per entity: two live monsters of that species would share * the dwell counter and the distance. */ #include "mhfu/call.h" #include "mhfu/charge.h" #include "mhfu/em_vhook.h" +#include "mhfu/entity.h" #include "mhfu/events.h" #include "mhfu/fatigue.h" #include "mhfu/hooks.h" @@ -1771,6 +1772,23 @@ extern "C" int mhfu_em_sever_gate(int pct) return 1; } +/* ent is a big monster, so its species vtable may take the latch. The vtable band alone passes + * any species, a Popo's too, and the first spawn seen took the latch. A big monster is one of the + * framework's species list (a port's host, which a swap may spawn under another target's record) + * or of the quest's target groups (the ADD path's groups too). */ +static int latches(uint32_t ent) +{ + if (mhfu_entity_is_bigmonster(ent)) return 1; + const uint8_t species = mhfu_mem_read_u8(ent + MHFU_ENTITY_SPECIES); + uint32_t groups = mhfu_mem_read_u32(MHFU_QUEST_SINGLETON + MHFU_QUEST_GROUP_COUNT); + if (groups > MHFU_QUEST_TARGETS_COUNT) groups = MHFU_QUEST_TARGETS_COUNT; + for (uint32_t k = 0; k < groups; k++) { + const uint32_t t = MHFU_QUEST_SINGLETON + MHFU_QUEST_TARGETS + k * MHFU_QUEST_TARGET_SIZE; + if ((mhfu_mem_read_u32(t + MHFU_QUEST_TARGET_EM_ID) & 0xFF) == species) return 1; + } + return 0; +} + /* A new quest: no move is ours and the board belongs to no monster yet. */ static void quest_reset(void) { @@ -1872,6 +1890,11 @@ static int wrap_gate(void) static void install_for(uint32_t entity) { if (g_installed || !entity) return; + if (!latches(entity)) { + mhfu_log("[%s] entity 0x%08X species 0x%02X is no target of the quest - skipping", OWNER, + (unsigned)entity, (unsigned)mhfu_mem_read_u8(entity + MHFU_ENTITY_SPECIES)); + return; + } if (alloc_block() < 0) return; uint32_t vt = mhfu_mem_read_u32(entity); /* every species entity vtable lies in this band: a corrupt pointer never @@ -2005,6 +2028,9 @@ extern "C" int mhfu_em_init(void) return 0; } +/* install_for's gate on a spawned entity */ +extern "C" int mhfu_em_host_latches(uint32_t ent) { return latches(ent); } + /* the species vtable latched, as install_for's */ extern "C" void mhfu_em_host_latch(uint32_t vtable) { T->vtable = vtable; } diff --git a/framework/src/core/registry_watch.h b/framework/src/core/registry_watch.h new file mode 100644 index 00000000..440ce981 --- /dev/null +++ b/framework/src/core/registry_watch.h @@ -0,0 +1,37 @@ +/* SPDX-License-Identifier: MIT */ +/* SPDX-FileCopyrightText: 2026 sp00ktober */ +/* The registry poll's memory: the pointer each slot last reported. Free of PSP headers, so the + * host test compiles it (tests/registry_host.cpp). The registry is emptied when a quest ends and + * refilled when the next loads, both while the poll sleeps (it runs only in an area), and the + * engine gives a monster the address it had before; so a new quest makes the poll forget, or its + * monsters read as unchanged and are never reported. */ +#ifndef MHFU_CORE_REGISTRY_WATCH_H +#define MHFU_CORE_REGISTRY_WATCH_H + +#include +#include "addresses.gen.h" + +typedef struct { + uint32_t last[MHFU_ENTITY_REGISTRY_COUNT]; /* the poll thread's alone */ + volatile uint32_t forget; /* set from any thread */ +} registry_watch_t; + +static inline void watch_forget(registry_watch_t *w) { w->forget = 1; } + +/* the start of a poll pass: carries out a forget on the poll's own thread */ +static inline void watch_begin(registry_watch_t *w) +{ + if (!w->forget) return; + w->forget = 0; + for (int k = 0; k < MHFU_ENTITY_REGISTRY_COUNT; k++) w->last[k] = 0; +} + +/* `cur` is what slot holds (0 for empty): 1 when it is a monster to report */ +static inline int watch_spawned(registry_watch_t *w, int slot, uint32_t cur) +{ + if (cur == w->last[slot]) return 0; + w->last[slot] = cur; + return cur != 0; +} + +#endif diff --git a/framework/src/core/world_events.cpp b/framework/src/core/world_events.cpp index 52cc23f7..4a60f51f 100644 --- a/framework/src/core/world_events.cpp +++ b/framework/src/core/world_events.cpp @@ -9,11 +9,13 @@ #include "mhfu/world.h" #include "mhfu/entity.h" #include "internal.h" +#include "registry_watch.h" #include "addresses.gen.h" static uint32_t g_last_quest_timer; static uint16_t g_last_area_index; static volatile int g_quest_armed; /* QUEST_BEGINNING fired, QUEST_ENTERED not yet */ +static registry_watch_t g_watch; /* what the registry poll reported (below) */ static void fill_ctx(mhfu_event_ctx_t *c, mhfu_event_id_t id, uint32_t cell, const mhfu_regs_t *r) @@ -31,6 +33,9 @@ extern "C" void mhfu_event_dispatch_quest_beginning(mhfu_regs_t *regs) if (prev != 0 || cur == 0) return; /* the 0 -> non-zero edge: the quest commit */ g_quest_armed = 1; + /* The last quest's monsters are gone from the registry by now (it empties when a quest ends) + * and the new ones not yet in it, so nothing stale can be reported again. */ + watch_forget(&g_watch); mhfu_event_ctx_t ctx; fill_ctx(&ctx, MHFU_EVENT_QUEST_BEGINNING, cur, regs); mhfu_event_fire(MHFU_EVENT_QUEST_BEGINNING, &ctx); @@ -65,8 +70,6 @@ extern "C" void mhfu_event_dispatch_quest_entered(mhfu_regs_t *regs) /* Main and extra RAM: the engine ticks entity clones in the extra-RAM window too. */ static int entity_in_ram(uint32_t e) { return e >= MHFU_MAIN_RAM && e < MHFU_EXTRA_RAM_END; } -static uint32_t g_last_entity[MHFU_ENTITY_REGISTRY_COUNT]; - static void dispatch_monster_spawned(int slot, uint32_t ent) { uint8_t type = mhfu_entity_monster_type(ent); @@ -90,14 +93,13 @@ extern "C" int mhfu_event_spawn_poll_thread(SceSize args, void *argp) (void)args; (void)argp; for (;;) { sceKernelDelayThread(200 * 1000); /* 5 Hz */ + watch_begin(&g_watch); /* reading game memory while the savedata utility starts froze the save load */ if (!mhfu_world_ms0_io_safe()) continue; for (int slot = 1; slot < MHFU_ENTITY_REGISTRY_COUNT; slot++) { uint32_t cur = mhfu_entity_at(slot); if (!entity_in_ram(cur)) cur = 0; - if (cur == g_last_entity[slot]) continue; - g_last_entity[slot] = cur; - if (cur) dispatch_monster_spawned(slot, cur); + if (watch_spawned(&g_watch, slot, cur)) dispatch_monster_spawned(slot, cur); } mhfu_ai_poll_death(); } diff --git a/framework/tests/em_host.cpp b/framework/tests/em_host.cpp index ee43c330..1cb967e6 100644 --- a/framework/tests/em_host.cpp +++ b/framework/tests/em_host.cpp @@ -13,6 +13,8 @@ static uint8_t g_mem[SIZE]; static uint8_t g_hunter[0x300]; +static uint8_t g_quest[0x900]; /* the quest singleton, MHFU_QUEST_SINGLETON */ +static uint32_t g_big_ret; /* what mhfu_entity_is_bigmonster answers */ static uint32_t g_calls[64][5]; static int g_n; static float g_clip_end = 100; @@ -26,6 +28,8 @@ static uint8_t *at(uint32_t a, uint32_t n) if (a >= MHFU_BREAK_TABLE && a + n <= MHFU_BREAK_TABLE + sizeof(g_breaks)) return g_breaks + (a - MHFU_BREAK_TABLE); if (a >= BASE && a + n <= BASE + SIZE) return g_mem + (a - BASE); + if (a >= MHFU_QUEST_SINGLETON && a + n <= MHFU_QUEST_SINGLETON + sizeof(g_quest)) + return g_quest + (a - MHFU_QUEST_SINGLETON); if (a >= MHFU_PLAYER_ENTITY && a + n <= MHFU_PLAYER_ENTITY + sizeof(g_hunter)) return g_hunter + (a - MHFU_PLAYER_ENTITY); return 0; @@ -51,6 +55,16 @@ void *mhfu_host_at(uint32_t a) { return at(a, 4); } void mhfu_log(const char *, ...) {} uint32_t mhfu_host_usec(void) { return 0; } void mhfu_event_fire(mhfu_event_id_t, const void *) {} +int mhfu_entity_is_bigmonster(uint32_t) { return (int)g_big_ret; } + +/* the quest's target groups: n of them, group k holding species em_id */ +void host_groups(uint32_t n) { memcpy(g_quest + MHFU_QUEST_GROUP_COUNT, &n, 4); } +void host_target(int k, uint32_t em_id) +{ + memcpy(g_quest + MHFU_QUEST_TARGETS + k * MHFU_QUEST_TARGET_SIZE + MHFU_QUEST_TARGET_EM_ID, + &em_id, 4); +} +void host_big(uint32_t ret) { g_big_ret = ret; } void mhfu_host_enter(uint32_t e, uint32_t m, uint32_t s, uint32_t mode) { @@ -127,6 +141,8 @@ void host_reset(float clip_end) memset(g_breaks, 0, sizeof(g_breaks)); memset(g_mem, 0, sizeof(g_mem)); memset(g_hunter, 0, sizeof(g_hunter)); + memset(g_quest, 0, sizeof(g_quest)); + g_big_ret = 0; g_clip_end = clip_end; g_gate_ret = 1; g_n = 0; diff --git a/framework/tests/registry_host.cpp b/framework/tests/registry_host.cpp new file mode 100644 index 00000000..56a54f0b --- /dev/null +++ b/framework/tests/registry_host.cpp @@ -0,0 +1,14 @@ +/* SPDX-License-Identifier: MIT */ +/* SPDX-FileCopyrightText: 2026 sp00ktober */ +/* registry_watch.h for the host test, through ctypes. */ +#include +#include "registry_watch.h" + +static registry_watch_t g_w; + +extern "C" { +void reg_host_reset(void) { memset(&g_w, 0, sizeof(g_w)); } +void reg_host_forget(void) { watch_forget(&g_w); } +void reg_host_begin(void) { watch_begin(&g_w); } +int reg_host_spawned(int slot, uint32_t cur) { return watch_spawned(&g_w, slot, cur); } +} diff --git a/framework/tests/test_em_brain.py b/framework/tests/test_em_brain.py index a08fea05..dd784e13 100644 --- a/framework/tests/test_em_brain.py +++ b/framework/tests/test_em_brain.py @@ -389,6 +389,10 @@ def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: lib.mhfu_em_host_reaction.argtypes = [ctypes.c_uint32, ctypes.c_uint8, ctypes.c_uint8] lib.mhfu_em_host_brain.argtypes = [ctypes.c_uint32] lib.mhfu_em_host_latch.argtypes = [ctypes.c_uint32] + lib.mhfu_em_host_latches.argtypes = [ctypes.c_uint32] + lib.host_groups.argtypes = [ctypes.c_uint32] + lib.host_target.argtypes = [ctypes.c_int, ctypes.c_uint32] + lib.host_big.argtypes = [ctypes.c_uint32] lib.mhfu_em_host_spawn.argtypes = [ctypes.c_uint32] lib.mhfu_em_var.argtypes = [ctypes.c_int] lib.mhfu_em_set_var.argtypes = [ctypes.c_int, ctypes.c_int] @@ -1619,6 +1623,56 @@ def test_the_second_foreleg_break_reaches_its_rule(g: Game) -> None: assert g.entered() == [(3, 7)] and g.board("BROKEN") == (1 << 4) | (1 << 6) +TIGREX_ID, POPO_ID = 0x4B, 0x46 + + +def latches(g: Game, species: int) -> int: + g.poke(a.ENTITY.SPECIES, "B", species) + return int(g.lib.mhfu_em_host_latches(ENT)) + + +def test_only_a_target_species_latches(g: Game) -> None: + g.lib.host_groups(1) + g.lib.host_target(0, TIGREX_ID) + assert latches(g, TIGREX_ID) == 1 and latches(g, POPO_ID) == 0 + + +def test_every_target_group_counts(g: Game) -> None: + g.lib.host_groups(2) + g.lib.host_target(0, TIGREX_ID) + g.lib.host_target(1, 0x4D) + assert latches(g, 0x4D) == 1 and latches(g, POPO_ID) == 0 + + +def test_a_group_past_the_count_does_not_count(g: Game) -> None: + g.lib.host_groups(1) + g.lib.host_target(0, TIGREX_ID) + g.lib.host_target(1, POPO_ID) # stale: the count is 1 + assert latches(g, POPO_ID) == 0 + + +def test_a_wild_group_count_stops_at_the_groups_there_are(g: Game) -> None: + g.lib.host_groups(0xFFFFFFFF) + g.lib.host_target(0, TIGREX_ID) + assert latches(g, TIGREX_ID) == 1 and latches(g, POPO_ID) == 0 + + +def test_a_host_spawned_under_another_target_latches(g: Game) -> None: + g.lib.host_groups(1) + g.lib.host_target(0, 0x4D) # a swap's record names the replaced species + g.lib.host_big(1) + assert latches(g, TIGREX_ID) == 1 + g.lib.host_big(0) + assert latches(g, POPO_ID) == 0 + + +def test_a_quest_without_groups_asks_the_species_list(g: Game) -> None: + g.lib.host_big(1) + assert latches(g, TIGREX_ID) == 1 + g.lib.host_big(0) + assert latches(g, POPO_ID) == 0 + + HUB, SCRIPT_PAIR = (0, 2), (1, 3) # the carrier of g.own; the pair the AI script enters next diff --git a/framework/tests/test_registry_watch.py b/framework/tests/test_registry_watch.py new file mode 100644 index 00000000..cc5c5f76 --- /dev/null +++ b/framework/tests/test_registry_watch.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 sp00ktober +"""The registry poll's memory (src/core/registry_watch.h): a monster is reported when its slot's +pointer changes, and a new quest makes the poll forget, so a monster that comes back at the same +address is reported again.""" + +import ctypes +from collections.abc import Callable + +import pytest + +TIGREX, POPO = 0x090C1B00, 0x090CDC00 # noaddr + + +@pytest.fixture(scope="module") +def lib(host_lib: Callable[..., ctypes.CDLL]) -> ctypes.CDLL: + lib = host_lib("tests/registry_host.cpp") + lib.reg_host_spawned.argtypes = [ctypes.c_int, ctypes.c_uint32] + return lib + + +@pytest.fixture +def poll(lib: ctypes.CDLL) -> Callable[..., list[int]]: + """One pass over the slots given as {slot: pointer}; the slots reported.""" + lib.reg_host_reset() + + def run(slots: dict[int, int]) -> list[int]: + lib.reg_host_begin() + return [s for s, p in slots.items() if lib.reg_host_spawned(s, p)] + + return run + + +def test_a_pointer_is_reported_once(poll: Callable[..., list[int]]) -> None: + assert poll({1: TIGREX, 3: POPO}) == [1, 3] + assert poll({1: TIGREX, 3: POPO}) == [] + + +def test_an_empty_slot_is_not_a_spawn(poll: Callable[..., list[int]]) -> None: + assert poll({1: 0}) == [] + assert poll({1: TIGREX}) == [1] + assert poll({1: 0}) == [] + assert poll({1: TIGREX}) == [1] + + +def test_a_new_quest_reports_what_comes_back( + lib: ctypes.CDLL, poll: Callable[..., list[int]] +) -> None: + assert poll({1: TIGREX, 3: POPO}) == [1, 3] + lib.reg_host_forget() + assert poll({1: TIGREX, 3: 0}) == [1] + assert poll({1: TIGREX, 3: POPO}) == [3] + + +def test_a_forget_is_taken_once(lib: ctypes.CDLL, poll: Callable[..., list[int]]) -> None: + assert poll({1: TIGREX}) == [1] + lib.reg_host_forget() + lib.reg_host_forget() + assert poll({1: TIGREX}) == [1] + assert poll({1: TIGREX}) == []