Skip to content

Commit 5a0ca1b

Browse files
sp00ktoberclaude
andauthored
feat(framework): the Lua API declared once, require and api_version (#25)
* feat(framework): the Lua API is declared once, require and api_version lua/meta/mhfu.d.lua declares every binding; tools/lua_api.py renders the registered table from it, and -Werror=missing-declarations makes the two sides fail the build when they disagree. require reads mods/lib/ and reloads a changed library in place; boot lines are written after the script load. Freecam's bindings leave the API until it is installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(framework): a Lua runtime with the declared mhfu faked Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(framework): the ported-monster runtime is a library, mhfu_port A mod does `local port = require("mhfu_port")` and `port.mod(name, fn)`, which runs its setup at once; the mhfu.port global and the queue bootstrap are gone. It refuses to load below api_version 1, and takes the player entity, the species table and its struct offsets from mhfu.addr. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(framework): three example Lua mods to start from An action override on the executor, a second Tigrex through the ADD path, and a ported Brute on mhfu_port, each tested in lupa against the declared API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(framework): mhfu_port's log queue survives a library reload Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(framework): every mhfu binding typed and documented for LuaLS mhfu.d.lua declares params, returns and one doc line per binding, with classes for the event contexts and em_status/em_rule tables. The prelude annotates its handles where the code lives (mhfu is a partial class there, so no inject-field). Binding comments in lua_host point at the declaration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(framework): mhfu_port's claimed moves typed for LuaLS Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(framework): ported_brute reports instead of a charge the engine refuses Live, the manifest's charge (2,8) bounced to (0,1) within a tick when requested, and (1,4) parked in its last phase, so the example no longer scripts a move; it shows the port loading and a brain that reports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent da4e9b1 commit 5a0ca1b

36 files changed

Lines changed: 2751 additions & 492 deletions

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,30 @@ jobs:
4040
- run: uv sync --locked
4141
- run: uv run pytest
4242

43+
# the Lua that ships against the mhfu API it declares (framework/lua/meta/mhfu.d.lua)
44+
lua:
45+
runs-on: ubuntu-latest
46+
timeout-minutes: 5
47+
env:
48+
LUALS: 3.19.1
49+
LUALS_SHA256: e9235d2d72ef55bc41cf8c99cda2ed64777682024b4bb81f5dea425060c5cbb8
50+
steps:
51+
- uses: actions/checkout@v7
52+
- uses: astral-sh/setup-uv@v10.2.0
53+
with:
54+
enable-cache: true
55+
- run: uv sync --locked
56+
- run: |
57+
mkdir -p framework/build/gen
58+
uv run python -m mhfu.addresses lua -o framework/build/gen/addresses.gen.lua
59+
- name: lua-language-server --check
60+
run: |
61+
cd "$RUNNER_TEMP"
62+
curl -fsSLo luals.tar.gz "https://github.com/LuaLS/lua-language-server/releases/download/$LUALS/lua-language-server-$LUALS-linux-x64.tar.gz"
63+
echo "$LUALS_SHA256 luals.tar.gz" | sha256sum -c
64+
mkdir luals && tar xzf luals.tar.gz -C luals
65+
luals/bin/lua-language-server --check="$GITHUB_WORKSPACE" --checklevel=Warning --logpath="$RUNNER_TEMP/luals-log"
66+
4367
# advisory until the first release: breaking changes show up as annotations on the PR
4468
api:
4569
if: github.event_name == 'pull_request'

‎.luarc.json‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
3+
"runtime.version": "Lua 5.4",
4+
"runtime.builtin": {
5+
"coroutine": "disable",
6+
"debug": "disable",
7+
"io": "disable",
8+
"os": "disable",
9+
"utf8": "disable"
10+
},
11+
"workspace.library": ["framework/build/gen/addresses.gen.lua"],
12+
"workspace.checkThirdParty": false
13+
}

‎framework/Makefile‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ PRXS = build/mhfu_framework.prx build/mhfu_boot.prx
99
.PHONY: all clean
1010

1111
# psp-prxgen copies EF_MIPS_ABI_EABI32 into the PRX, which old firmware rejects (0x800200D9)
12-
all: $(GEN)/addresses.gen.h $(GEN)/addresses.lua
12+
all: $(GEN)/addresses.gen.h $(GEN)/addresses.lua $(GEN)/lua_api.gen.h $(GEN)/prelude.lua
1313
docker run --rm --platform linux/amd64 --user "$$(id -u):$$(id -g)" -v "$(CURDIR)":/work -w /work \
1414
$(IMAGE) make -f Makefile.psp $(if $(findstring k,$(firstword -$(MAKEFLAGS))),-k)
1515
uv run python tools/patch_prx_eflags.py $(PRXS)
@@ -26,5 +26,14 @@ $(GEN)/addresses.gen.lua: $(ADDR).toml $(ADDR).py
2626
$(GEN)/addresses.lua: $(GEN)/addresses.gen.lua
2727
grep -v '^[[:space:]]*---' $< > $@
2828

29+
# the Lua API's registration table and prototypes (lua_api.gen.inc comes with the header)
30+
$(GEN)/lua_api.gen.h: lua/meta/mhfu.d.lua tools/lua_api.py
31+
uv run python tools/lua_api.py $< -o $(GEN)
32+
33+
# the prelude the PRX embeds, annotations blanked so line numbers in its errors still hold
34+
$(GEN)/prelude.lua: lua/lib/_prelude.lua
35+
@mkdir -p $(@D)
36+
sed 's/^[[:space:]]*---.*$$//' $< > $@
37+
2938
clean:
3039
rm -rf build

‎framework/Makefile.psp‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ INCDIR = include src/core $(GEN)
3131
CFLAGS = -Os -G0 -Wall -fno-strict-aliasing
3232
# no exceptions, RTTI or static guards: the objects need no libstdc++ runtime
3333
CXXFLAGS = -std=gnu++17 -fno-exceptions -fno-rtti -fno-threadsafe-statics
34-
# .incbin in a mod's .S finds the Lua sources and the generated address table
35-
ASFLAGS = -Wa,-Ilua,-I$(GEN)
34+
# .incbin in a mod's .S finds the generated prelude and address table
35+
ASFLAGS = -Wa,-I$(GEN)
36+
# a Lua binding defined but not declared in lua/meta/mhfu.d.lua fails here; declared but not
37+
# defined fails the link
38+
$(OBJDIR)/mods/lua_host/%.o: CXXFLAGS += -Werror=missing-declarations
3639

3740
# prxexports.o: real firmware rejects a PRX without the syslib export (0x800200D9); lua needs libm, libc
3841
LIBS = $(PSPSDK)/lib/prxexports.o -lpspsdk -lpspuser -lpspusb -lpspnet -lpspnet_apctl -llua -lm -lc

‎framework/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ make -C framework clean
1919
| `build/mhfu_boot.prx` | the real-PSP loader: a kernel plugin that loads the framework from its own directory once the game shows the title or a menu |
2020
| `build/*.elf` | the same with symbols and relocations |
2121
| `build/gen/addresses.gen.h`, `.lua` | the address map, rendered from `packages/mhfu`; Lua reads it as `mhfu.addr` |
22+
| `build/gen/lua_api.gen.h`, `.inc` | the `mhfu` table's C side, rendered from `lua/meta/mhfu.d.lua` |
2223

2324
`mods.manifest` lists the mods linked into the plugin, one directory under `mods/` per line; `#`
2425
disables one. Every mod directory is compiled either way.
@@ -31,6 +32,7 @@ PSP/PLUGINS/mhfu_framework/
3132
mhfu_boot.prx real PSP only
3233
plugin.ini PPSSPP only
3334
mods/*.lua Lua mods, loaded at boot and reloaded when they change
35+
mods/lib/*.lua Lua libraries, run by require and reloaded when they change
3436
framework.log written at run time
3537
```
3638

@@ -56,3 +58,22 @@ ms0:/PSP/PLUGINS/mhfu_framework/mhfu_boot.prx 1
5658
```
5759

5860
The plugin loads on a cold boot only; after loading a PPSSPP savestate it no longer runs.
61+
62+
## Lua mods
63+
64+
| `lua/` | |
65+
|---|---|
66+
| `meta/mhfu.d.lua` | the `mhfu` API, every function and constant with its doc; the build registers exactly what it declares |
67+
| `lib/` | `_prelude.lua`, embedded and run before any mod, and `mhfu_port.lua`, the ported-monster runtime |
68+
| `tools/` | `cli_bridge.lua`, the in-game side of `mhfu shell` |
69+
| `examples/` | small mods to start from |
70+
71+
A mod is a `.lua` file in `mods/`. A library in `mods/lib/` runs once, when a mod first requires
72+
it: `local port = require("mhfu_port")`. Require at a mod's top level; later, while the Memory Stick
73+
may be busy, `require` refuses to read a new library. A mod that needs a newer API checks
74+
`mhfu.api_version`. Errors from the boot load are written to `framework.log` right after it.
75+
76+
An editor with the Lua language server (VS Code's Lua extension) gets completion and hover docs
77+
from `.luarc.json` at the repo root; `mhfu.addr` needs `make -C framework
78+
build/gen/addresses.gen.lua` once. Outside this repo, add `framework/lua/meta` and
79+
`framework/lua/lib` to `workspace.library`.

‎framework/include/mhfu/log.h‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ extern "C" {
99

1010
void mhfu_log(const char *fmt, ...) __attribute__((format(printf, 1, 2)));
1111
void mhfu_log_close(void);
12+
/* Writes the held lines now: only where ms0 is being read anyway (lua_host's boot load). */
13+
void mhfu_log_flush_held(void);
1214

1315
#ifdef __cplusplus
1416
} /* extern "C" */
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
-- action_override.lua: a Tigrex shows his spin animation in place of one of his moves, about
2+
-- every 10 s while he is in your section.
3+
--
4+
-- Run it in a quest with a Tigrex: one where he is native (Absolute Power), or the Elder's
5+
-- 2-star Giadrome quest next to second_monster.lua, which swaps one in. framework.log shows
6+
-- "[action_override] a1 N -> 43 (spin)" each time.
7+
--
8+
-- This changes what you SEE, not what he DOES. on_bigmonster_action rewrites the executor's
9+
-- a1, which picks the clip; the move itself (its timing, hitbox and damage) is the (main, sub)
10+
-- pair his brain chose, and it runs on underneath. The spin also lasts only until that move asks
11+
-- for its next clip. To change the move, write the pair: mhfu_port's play() (ported_brute.lua).
12+
--
13+
-- Replace one dispatch now and then; answering every dispatch restarts the clip each time, so
14+
-- no animation plays through, and halts his AI. Lua mods share one handler per event, the last
15+
-- to register wins: don't run this next to cli_bridge.lua or a mhfu_port mod.
16+
17+
local SPIN = 43 -- a Tigrex's spin clip; a1 ids are per species
18+
local EVERY = 300 -- quest-timer frames (30 Hz) between swaps
19+
local GATE = mhfu.addr.ENTITY.FREEZE_GATE
20+
local FROZEN = 0x100 | 0x10000 -- the gate bits that halt the AI tick
21+
22+
local last = {} -- entity -> quest timer at its last swap
23+
24+
mhfu.on_bigmonster_action(function(ctx)
25+
local ent = ctx.entity
26+
if ctx.type ~= mhfu.MON_TIGREX or mhfu.entity_section(ent) ~= mhfu.get_area_index() then
27+
return nil
28+
end
29+
local now, prev = mhfu.get_quest_timer(), last[ent] -- the timer counts down
30+
if prev and now <= prev and prev - now < EVERY then return nil end
31+
last[ent] = now
32+
-- a forced action can set the freeze gate; clear it with the swap, never per tick
33+
local gate = mhfu.read_u32(ent + GATE)
34+
if gate & FROZEN ~= 0 then mhfu.write_u32(ent + GATE, gate & ~FROZEN) end
35+
mhfu.log(string.format("[action_override] a1 %d -> %d (spin)", ctx.action_id, SPIN))
36+
return SPIN
37+
end)
38+
39+
mhfu.log("[action_override] ready")
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
-- ported_brute.lua: the Brute Tigrex, ported from MHP3rd, in place of a Giadrome.
2+
--
3+
-- Needs, once: the port built and placed for injection, from modkit with $MHFU_DATA and
4+
-- $MHP3RD_DATA naming the extracted games,
5+
-- uv run mhfu-port inject ports/brute_tigrex.toml
6+
-- (it writes brute_tigrex.bin and file_06185.bin.orig to the memory stick's
7+
-- PLUGINS/mhfu_framework/inject/); mhfu_port.lua in mods/lib/; `memory = 64` in plugin.ini.
8+
--
9+
-- Cold boot into the Village Elder's 2-star Giadrome quest (`mhfu go-on-quest --rank 1 --quest
10+
-- Giadrome`). The Giadrome becomes a Tigrex wearing the Brute's model, skeleton and clips, and
11+
-- he fights with the Tigrex's brain. The brain here only reports: framework.log shows
12+
-- "[ported_brute] in your section, D units away" when he comes to you.
13+
--
14+
-- A brain can also script moves: brute:play(name) enters a host (main, sub) pair declared in
15+
-- P.define's `moves` and shows the port's clip with it. The engine bounces or parks a pair it
16+
-- does not enter on its own, so pick pairs that hold when forced (`mhfu census`); mhfu_port logs
17+
-- which happened. No move is verified for this port yet.
18+
--
19+
-- The P.define values mirror ports/brute_tigrex.toml. A brain runs at 2 Hz, and mhfu_port owns
20+
-- the one mhfu_tick: a mod never defines it.
21+
22+
local port = require("mhfu_port")
23+
24+
port.mod("ported_brute", function(P)
25+
local brute = P.define{
26+
name = "brute_tigrex",
27+
species = mhfu.MON_TIGREX,
28+
replace = { mhfu.MON_GIADROME },
29+
pac = "brute_tigrex.bin",
30+
orig = "file_06185.bin.orig",
31+
fid = 6186,
32+
}
33+
34+
local here = false
35+
brute:brain(function(s)
36+
if s.same_section == here then return end
37+
here = s.same_section
38+
if here then
39+
P.log("[ported_brute] in your section, %d units away", math.floor(s.dist))
40+
else
41+
P.log("[ported_brute] left your section")
42+
end
43+
end)
44+
end)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- second_monster.lua: a second Tigrex in the quest, one that fights like the first.
2+
--
3+
-- Run it in a quest with a native Tigrex (Absolute Power), or in the Village Elder's 2-star
4+
-- Giadrome quest (`mhfu go-on-quest --rank 1 --quest Giadrome`), whose Giadrome becomes a
5+
-- Tigrex first. framework.log shows "[second_monster] added a second Tigrex" as the quest
6+
-- loads, then one "Tigrex in slot N" line for each. Both start where the quest's own monster
7+
-- starts and roam from there; in the Snowy Mountains a Tigrex roams sections 1, 3, 6, 7 and 8.
8+
--
9+
-- quest_add_monster works only from on_quest_targets_building: it gives the monster a target
10+
-- group of its own, so the engine provisions it fully and it deals damage. A quest holds two
11+
-- groups, so two damaging big monsters is the ceiling. The quest has one AI overlay, the first
12+
-- monster's, so the added one must be the same species; the framework knows only the Tigrex.
13+
--
14+
-- Don't move, resize or re-flag them every tick to make them show up: that is what breaks a big
15+
-- monster's fighting. One out of your section is invisible until it roams in. Lua mods share
16+
-- one handler per event, the last to register wins: don't run this next to a mhfu_port mod.
17+
18+
local TIGREX, GIADROME = mhfu.MON_TIGREX, mhfu.MON_GIADROME
19+
20+
mhfu.on_quest_targets_building(function(quest)
21+
if quest == 0 then return end
22+
if mhfu.quest_has(quest, GIADROME) then mhfu.quest_replace_monster(quest, GIADROME, TIGREX) end
23+
if mhfu.quest_has(quest, TIGREX) and mhfu.quest_add_monster(quest, TIGREX) then
24+
mhfu.log("[second_monster] added a second Tigrex")
25+
end
26+
end)
27+
28+
mhfu.on_bigmonster_spawn(function(_, species, slot, hp)
29+
if species == TIGREX then
30+
mhfu.log(string.format("[second_monster] Tigrex in slot %d, hp %d", slot, hp))
31+
end
32+
end)
33+
34+
mhfu.log("[second_monster] ready")

0 commit comments

Comments
 (0)