Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion framework/include/mhfu/events.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 28 additions & 2 deletions framework/src/core/em_vhook.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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; }

Expand Down
37 changes: 37 additions & 0 deletions framework/src/core/registry_watch.h
Original file line number Diff line number Diff line change
@@ -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 <stdint.h>
#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
12 changes: 7 additions & 5 deletions framework/src/core/world_events.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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();
}
Expand Down
16 changes: 16 additions & 0 deletions framework/tests/em_host.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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)
{
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions framework/tests/registry_host.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* SPDX-License-Identifier: MIT */
/* SPDX-FileCopyrightText: 2026 sp00ktober */
/* registry_watch.h for the host test, through ctypes. */
#include <string.h>
#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); }
}
54 changes: 54 additions & 0 deletions framework/tests/test_em_brain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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


Expand Down
60 changes: 60 additions & 0 deletions framework/tests/test_registry_watch.py
Original file line number Diff line number Diff line change
@@ -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}) == []
Loading