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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,30 @@ jobs:
- run: uv sync --locked
- run: uv run pytest

# the Lua that ships against the mhfu API it declares (framework/lua/meta/mhfu.d.lua)
lua:
runs-on: ubuntu-latest
timeout-minutes: 5
env:
LUALS: 3.19.1
LUALS_SHA256: e9235d2d72ef55bc41cf8c99cda2ed64777682024b4bb81f5dea425060c5cbb8
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.2.0
with:
enable-cache: true
- run: uv sync --locked
- run: |
mkdir -p framework/build/gen
uv run python -m mhfu.addresses lua -o framework/build/gen/addresses.gen.lua
- name: lua-language-server --check
run: |
cd "$RUNNER_TEMP"
curl -fsSLo luals.tar.gz "https://github.com/LuaLS/lua-language-server/releases/download/$LUALS/lua-language-server-$LUALS-linux-x64.tar.gz"
echo "$LUALS_SHA256 luals.tar.gz" | sha256sum -c
mkdir luals && tar xzf luals.tar.gz -C luals
luals/bin/lua-language-server --check="$GITHUB_WORKSPACE" --checklevel=Warning --logpath="$RUNNER_TEMP/luals-log"

# advisory until the first release: breaking changes show up as annotations on the PR
api:
if: github.event_name == 'pull_request'
Expand Down
13 changes: 13 additions & 0 deletions .luarc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"runtime.version": "Lua 5.4",
"runtime.builtin": {
"coroutine": "disable",
"debug": "disable",
"io": "disable",
"os": "disable",
"utf8": "disable"
},
"workspace.library": ["framework/build/gen/addresses.gen.lua"],
"workspace.checkThirdParty": false
}
11 changes: 10 additions & 1 deletion framework/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ PRXS = build/mhfu_framework.prx build/mhfu_boot.prx
.PHONY: all clean

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

# the Lua API's registration table and prototypes (lua_api.gen.inc comes with the header)
$(GEN)/lua_api.gen.h: lua/meta/mhfu.d.lua tools/lua_api.py
uv run python tools/lua_api.py $< -o $(GEN)

# the prelude the PRX embeds, annotations blanked so line numbers in its errors still hold
$(GEN)/prelude.lua: lua/lib/_prelude.lua
@mkdir -p $(@D)
sed 's/^[[:space:]]*---.*$$//' $< > $@

clean:
rm -rf build
7 changes: 5 additions & 2 deletions framework/Makefile.psp
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,11 @@ INCDIR = include src/core $(GEN)
CFLAGS = -Os -G0 -Wall -fno-strict-aliasing
# no exceptions, RTTI or static guards: the objects need no libstdc++ runtime
CXXFLAGS = -std=gnu++17 -fno-exceptions -fno-rtti -fno-threadsafe-statics
# .incbin in a mod's .S finds the Lua sources and the generated address table
ASFLAGS = -Wa,-Ilua,-I$(GEN)
# .incbin in a mod's .S finds the generated prelude and address table
ASFLAGS = -Wa,-I$(GEN)
# a Lua binding defined but not declared in lua/meta/mhfu.d.lua fails here; declared but not
# defined fails the link
$(OBJDIR)/mods/lua_host/%.o: CXXFLAGS += -Werror=missing-declarations

# prxexports.o: real firmware rejects a PRX without the syslib export (0x800200D9); lua needs libm, libc
LIBS = $(PSPSDK)/lib/prxexports.o -lpspsdk -lpspuser -lpspusb -lpspnet -lpspnet_apctl -llua -lm -lc
Expand Down
21 changes: 21 additions & 0 deletions framework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ make -C framework clean
| `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 |
| `build/*.elf` | the same with symbols and relocations |
| `build/gen/addresses.gen.h`, `.lua` | the address map, rendered from `packages/mhfu`; Lua reads it as `mhfu.addr` |
| `build/gen/lua_api.gen.h`, `.inc` | the `mhfu` table's C side, rendered from `lua/meta/mhfu.d.lua` |

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

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

The plugin loads on a cold boot only; after loading a PPSSPP savestate it no longer runs.

## Lua mods

| `lua/` | |
|---|---|
| `meta/mhfu.d.lua` | the `mhfu` API, every function and constant with its doc; the build registers exactly what it declares |
| `lib/` | `_prelude.lua`, embedded and run before any mod, and `mhfu_port.lua`, the ported-monster runtime |
| `tools/` | `cli_bridge.lua`, the in-game side of `mhfu shell` |
| `examples/` | small mods to start from |

A mod is a `.lua` file in `mods/`. A library in `mods/lib/` runs once, when a mod first requires
it: `local port = require("mhfu_port")`. Require at a mod's top level; later, while the Memory Stick
may be busy, `require` refuses to read a new library. A mod that needs a newer API checks
`mhfu.api_version`. Errors from the boot load are written to `framework.log` right after it.

An editor with the Lua language server (VS Code's Lua extension) gets completion and hover docs
from `.luarc.json` at the repo root; `mhfu.addr` needs `make -C framework
build/gen/addresses.gen.lua` once. Outside this repo, add `framework/lua/meta` and
`framework/lua/lib` to `workspace.library`.
2 changes: 2 additions & 0 deletions framework/include/mhfu/log.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ extern "C" {

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

#ifdef __cplusplus
} /* extern "C" */
Expand Down
39 changes: 39 additions & 0 deletions framework/lua/examples/action_override.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- action_override.lua: a Tigrex shows his spin animation in place of one of his moves, about
-- every 10 s while he is in your section.
--
-- Run it in a quest with a Tigrex: one where he is native (Absolute Power), or the Elder's
-- 2-star Giadrome quest next to second_monster.lua, which swaps one in. framework.log shows
-- "[action_override] a1 N -> 43 (spin)" each time.
--
-- This changes what you SEE, not what he DOES. on_bigmonster_action rewrites the executor's
-- a1, which picks the clip; the move itself (its timing, hitbox and damage) is the (main, sub)
-- pair his brain chose, and it runs on underneath. The spin also lasts only until that move asks
-- for its next clip. To change the move, write the pair: mhfu_port's play() (ported_brute.lua).
--
-- Replace one dispatch now and then; answering every dispatch restarts the clip each time, so
-- no animation plays through, and halts his AI. Lua mods share one handler per event, the last
-- to register wins: don't run this next to cli_bridge.lua or a mhfu_port mod.

local SPIN = 43 -- a Tigrex's spin clip; a1 ids are per species
local EVERY = 300 -- quest-timer frames (30 Hz) between swaps
local GATE = mhfu.addr.ENTITY.FREEZE_GATE
local FROZEN = 0x100 | 0x10000 -- the gate bits that halt the AI tick

local last = {} -- entity -> quest timer at its last swap

mhfu.on_bigmonster_action(function(ctx)
local ent = ctx.entity
if ctx.type ~= mhfu.MON_TIGREX or mhfu.entity_section(ent) ~= mhfu.get_area_index() then
return nil
end
local now, prev = mhfu.get_quest_timer(), last[ent] -- the timer counts down
if prev and now <= prev and prev - now < EVERY then return nil end
last[ent] = now
-- a forced action can set the freeze gate; clear it with the swap, never per tick
local gate = mhfu.read_u32(ent + GATE)
if gate & FROZEN ~= 0 then mhfu.write_u32(ent + GATE, gate & ~FROZEN) end
mhfu.log(string.format("[action_override] a1 %d -> %d (spin)", ctx.action_id, SPIN))
return SPIN
end)

mhfu.log("[action_override] ready")
44 changes: 44 additions & 0 deletions framework/lua/examples/ported_brute.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
-- ported_brute.lua: the Brute Tigrex, ported from MHP3rd, in place of a Giadrome.
--
-- Needs, once: the port built and placed for injection, from modkit with $MHFU_DATA and
-- $MHP3RD_DATA naming the extracted games,
-- uv run mhfu-port inject ports/brute_tigrex.toml
-- (it writes brute_tigrex.bin and file_06185.bin.orig to the memory stick's
-- PLUGINS/mhfu_framework/inject/); mhfu_port.lua in mods/lib/; `memory = 64` in plugin.ini.
--
-- Cold boot into the Village Elder's 2-star Giadrome quest (`mhfu go-on-quest --rank 1 --quest
-- Giadrome`). The Giadrome becomes a Tigrex wearing the Brute's model, skeleton and clips, and
-- he fights with the Tigrex's brain. The brain here only reports: framework.log shows
-- "[ported_brute] in your section, D units away" when he comes to you.
--
-- A brain can also script moves: brute:play(name) enters a host (main, sub) pair declared in
-- P.define's `moves` and shows the port's clip with it. The engine bounces or parks a pair it
-- does not enter on its own, so pick pairs that hold when forced (`mhfu census`); mhfu_port logs
-- which happened. No move is verified for this port yet.
--
-- The P.define values mirror ports/brute_tigrex.toml. A brain runs at 2 Hz, and mhfu_port owns
-- the one mhfu_tick: a mod never defines it.

local port = require("mhfu_port")

port.mod("ported_brute", function(P)
local brute = P.define{
name = "brute_tigrex",
species = mhfu.MON_TIGREX,
replace = { mhfu.MON_GIADROME },
pac = "brute_tigrex.bin",
orig = "file_06185.bin.orig",
fid = 6186,
}

local here = false
brute:brain(function(s)
if s.same_section == here then return end
here = s.same_section
if here then
P.log("[ported_brute] in your section, %d units away", math.floor(s.dist))
else
P.log("[ported_brute] left your section")
end
end)
end)
34 changes: 34 additions & 0 deletions framework/lua/examples/second_monster.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- second_monster.lua: a second Tigrex in the quest, one that fights like the first.
--
-- Run it in a quest with a native Tigrex (Absolute Power), or in the Village Elder's 2-star
-- Giadrome quest (`mhfu go-on-quest --rank 1 --quest Giadrome`), whose Giadrome becomes a
-- Tigrex first. framework.log shows "[second_monster] added a second Tigrex" as the quest
-- loads, then one "Tigrex in slot N" line for each. Both start where the quest's own monster
-- starts and roam from there; in the Snowy Mountains a Tigrex roams sections 1, 3, 6, 7 and 8.
--
-- quest_add_monster works only from on_quest_targets_building: it gives the monster a target
-- group of its own, so the engine provisions it fully and it deals damage. A quest holds two
-- groups, so two damaging big monsters is the ceiling. The quest has one AI overlay, the first
-- monster's, so the added one must be the same species; the framework knows only the Tigrex.
--
-- Don't move, resize or re-flag them every tick to make them show up: that is what breaks a big
-- monster's fighting. One out of your section is invisible until it roams in. Lua mods share
-- one handler per event, the last to register wins: don't run this next to a mhfu_port mod.

local TIGREX, GIADROME = mhfu.MON_TIGREX, mhfu.MON_GIADROME

mhfu.on_quest_targets_building(function(quest)
if quest == 0 then return end
if mhfu.quest_has(quest, GIADROME) then mhfu.quest_replace_monster(quest, GIADROME, TIGREX) end
if mhfu.quest_has(quest, TIGREX) and mhfu.quest_add_monster(quest, TIGREX) then
mhfu.log("[second_monster] added a second Tigrex")
end
end)

mhfu.on_bigmonster_spawn(function(_, species, slot, hp)
if species == TIGREX then
mhfu.log(string.format("[second_monster] Tigrex in slot %d, hp %d", slot, hp))
end
end)

mhfu.log("[second_monster] ready")
Loading
Loading