diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0582ebd..22de681e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 00000000..886b9e07 --- /dev/null +++ b/.luarc.json @@ -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 +} diff --git a/framework/Makefile b/framework/Makefile index 6aa25af4..3b57f13c 100644 --- a/framework/Makefile +++ b/framework/Makefile @@ -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) @@ -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 diff --git a/framework/Makefile.psp b/framework/Makefile.psp index 24420646..cdf249b3 100644 --- a/framework/Makefile.psp +++ b/framework/Makefile.psp @@ -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 diff --git a/framework/README.md b/framework/README.md index 80e021a9..e14bf728 100644 --- a/framework/README.md +++ b/framework/README.md @@ -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. @@ -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 ``` @@ -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`. diff --git a/framework/include/mhfu/log.h b/framework/include/mhfu/log.h index 90e5fe91..8f521290 100644 --- a/framework/include/mhfu/log.h +++ b/framework/include/mhfu/log.h @@ -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" */ diff --git a/framework/lua/examples/action_override.lua b/framework/lua/examples/action_override.lua new file mode 100644 index 00000000..5e4c54f8 --- /dev/null +++ b/framework/lua/examples/action_override.lua @@ -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") diff --git a/framework/lua/examples/ported_brute.lua b/framework/lua/examples/ported_brute.lua new file mode 100644 index 00000000..03d4ae80 --- /dev/null +++ b/framework/lua/examples/ported_brute.lua @@ -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) diff --git a/framework/lua/examples/second_monster.lua b/framework/lua/examples/second_monster.lua new file mode 100644 index 00000000..83a3f8d8 --- /dev/null +++ b/framework/lua/examples/second_monster.lua @@ -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") diff --git a/framework/lua/_prelude.lua b/framework/lua/lib/_prelude.lua similarity index 63% rename from framework/lua/_prelude.lua rename to framework/lua/lib/_prelude.lua index 7dfffe7a..448e6c58 100644 --- a/framework/lua/_prelude.lua +++ b/framework/lua/lib/_prelude.lua @@ -1,73 +1,95 @@ --- _prelude.lua — ergonomic OO layer over the flat mhfu.* C bindings. --- --- Run by lua_host BEFORE any user mod (embedded, not a memstick mod). Builds: --- * mhfu.mem.* — raw memory read/write (aliases of the flat bindings) --- * mhfu.world.* — global state: player, entities, screen/area, paint_map --- * Entity handles — mhfu.entity.wrap(ptr) -> object with :pos()/:force_aggro()/… --- * a Player handle — mhfu.world.player() --- --- The flat bindings (mhfu.read_u32, mhfu.entity_pos, …) stay available, so --- existing scripts keep working; this is purely additive sugar. +-- Handles over the flat mhfu bindings, run before every mod: mhfu.mem, mhfu.world, mhfu.entity +-- (Entity), mhfu.MON and mhfu.AREA. Annotations stay whole `---` lines: the embedded copy blanks +-- them (Makefile). +---@class mhfu local M = mhfu -------------------------------------------------------------------- mhfu.mem +---The raw memory bindings under short names; valid is mem_valid. M.mem = { read_u8 = M.read_u8, read_u16 = M.read_u16, read_u32 = M.read_u32, write_u8 = M.write_u8, write_u16= M.write_u16, write_u32= M.write_u32, read_f32 = M.read_f32, write_f32= M.write_f32, valid = M.mem_valid, } --- Accept a handle ({:pos()}), a {x,y,z}/{x=,y=,z=} table, or bare x,y,z. +---A handle, an {x, y, z} or {x=, y=, z=} table. +---@alias mhfu.Point mhfu.Entity|mhfu.Player|table + +---@param a mhfu.Point|number +---@param b? number +---@param c? number +---@return number x, number y, number z local function to_xyz(a, b, c) if type(a) == "table" then if a.pos then return a:pos() end if a.x then return a.x, a.y, a.z end return a[1], a[2], a[3] end + ---@cast a number + ---@cast b number + ---@cast c number return a, b, c end ----------------------------------------------------------------- Entity class +---A handle over an entity pointer: a method named like an mhfu.entity_* binding is that binding +---on ptr, and setters return the handle. +---@class mhfu.Entity +---@field ptr integer local Entity = {} Entity.__index = Entity +---A handle over ptr. +---@param ptr integer +---@return mhfu.Entity function M.entity_wrap(ptr) return setmetatable({ ptr = ptr }, Entity) end +---Entity handles: wrap is mhfu.entity_wrap. M.entity = M.entity or {} M.entity.wrap = M.entity_wrap +---True while ptr is nonzero and in the registry. +---@return boolean function Entity:valid() return self.ptr ~= 0 and M.entity_alive(self.ptr) end function Entity:type() return M.entity_type(self.ptr) end function Entity:hp() return M.entity_hp(self.ptr) end function Entity:size() return M.entity_size(self.ptr) end +---@param v number function Entity:set_size(v) M.entity_set_size(self.ptr, v); return self end function Entity:pos() return M.entity_pos(self.ptr) end +---@param a mhfu.Point|number +---@param b? number +---@param c? number function Entity:set_pos(a,b,c) local x,y,z = to_xyz(a,b,c); M.entity_set_pos(self.ptr,x,y,z); return self end function Entity:yaw() return M.entity_yaw(self.ptr) end +---@param v integer function Entity:set_yaw(v) M.entity_set_yaw(self.ptr, v); return self end function Entity:ai_state() return M.entity_ai_state(self.ptr) end +---@param v integer function Entity:set_ai_state(v) M.entity_set_ai_state(self.ptr, v); return self end function Entity:engaged() return M.entity_engaged(self.ptr) end +---@param b boolean function Entity:set_engaged(b) M.entity_set_engaged(self.ptr, b); return self end function Entity:section() return M.entity_section(self.ptr) end +---@param v integer function Entity:set_section(v) M.entity_set_section(self.ptr, v); return self end function Entity:calm() M.entity_calm(self.ptr); return self end --- Clone this monster (same species). Returns a NEW Entity handle (a real, --- ticking, rendered monster sharing this one's model/overlay) or nil. The --- species must be resident (clone a Tigrex only where a Tigrex is loaded). +---A same-species clone as a new handle, or nil (mhfu.entity_clone). +---@return mhfu.Entity? function Entity:clone() local p = M.entity_clone(self.ptr) if not p or p == 0 then return nil end return M.entity_wrap(p) end --- Render-fix: make him visible in `section` (default = the player's area). +---mhfu.entity_make_visible, in the player's area by default. +---@param section? integer function Entity:make_visible(section) M.entity_make_visible(self.ptr, section or M.get_area_index()); return self end --- XZ-plane distance to a target (handle / table / x,z). +---XZ distance to target. +---@param target mhfu.Point +---@return number function Entity:dist_to(target) local tx, _, tz = to_xyz(target) local mx, _, mz = self:pos() @@ -75,14 +97,16 @@ function Entity:dist_to(target) return math.sqrt(dx*dx + dz*dz) end --- Force this monster to hunt `target` (handle/table). Writes the full engine --- engage signature (pursuit vec + engage + ai_state + acquired) coherently. +---mhfu.entity_force_aggro on target's position. +---@param target mhfu.Point function Entity:force_aggro(target) local tx, ty, tz = to_xyz(target) M.entity_force_aggro(self.ptr, tx, ty, tz); return self end --- Teleport to `offset` units past `target` along +X, then fix visibility. +---Moves offset units (default 0) past target along +X, then make_visible(). +---@param target mhfu.Point +---@param offset? number function Entity:teleport_near(target, offset) local tx, ty, tz = to_xyz(target) self:set_pos(tx + (offset or 0), ty, tz) @@ -90,15 +114,20 @@ function Entity:teleport_near(target, offset) return self end ------------------------------------------------------------------ Player class +---The hunter, from mhfu.world.player(); pos is mhfu.player_pos. +---@class mhfu.Player local Player = {} Player.__index = Player +---@type mhfu.Player local PLAYER = setmetatable({}, Player) function Player:pos() return M.player_pos() end function Player:hp() return M.get_player_hp() end function Player:area() return M.get_area_index() end function Player:screen_state() return M.get_screen_state() end +---XZ distance to ent. +---@param ent mhfu.Point +---@return number function Player:dist_to(ent) local tx, _, tz = to_xyz(self) local mx, _, mz = to_xyz(ent) @@ -106,38 +135,41 @@ function Player:dist_to(ent) return math.sqrt(dx*dx + dz*dz) end -------------------------------------------------------------------- mhfu.world +---World state: the player, screen, area, quest clock and entity lookup. M.world = { player = function() return PLAYER end, screen_state = M.get_screen_state, area = M.get_area_index, quest_timer = M.get_quest_timer, + ---True in a quest area (screen state 17). in_area = function() return M.get_screen_state() == 17 end, paint_map = M.paint_map, } --- All live entities of a type, as handles. +---Every live entity of a monster type, as handles. +---@param type integer +---@return mhfu.Entity[] function M.world.entities(type) local out = {} for _, p in ipairs(M.entities_of_type(type)) do out[#out+1] = M.entity_wrap(p) end return out end --- First live entity of a type (or nil). +---The first live entity of a monster type, or nil. +---@param type integer +---@return mhfu.Entity? function M.world.first(type) local list = M.entities_of_type(type) if list and list[1] and list[1] ~= 0 then return M.entity_wrap(list[1]) end return nil end -------------------------------------------------------------------- enums --- Named monster ids (alias of the flat mhfu.MON_* constants). +---Monster ids by name: the mhfu.MON_* constants. M.MON = { ANTEKA = M.MON_ANTEKA, POPO = M.MON_POPO, TIGREX = M.MON_TIGREX, GIADROME = M.MON_GIADROME, } --- Snowy-mountains area_index values (== entity +0x29A section encoding). --- area_index alone identifies the map+section (it's a global id, not per-map), --- so AREA.SNOW_S1 == 99 reliably means "snowy mountains, section 1". +---Snowy-mountains area indices, as get_area_index and entity_section give them; an index names +---the map as well as the section. M.AREA = { BASECAMP = 98, SNOW_S1 = 99, SNOW_S6 = 100 } M.log("[prelude] OO layer ready (mhfu.world / mhfu.entity / mhfu.mem / mhfu.MON / mhfu.AREA)") diff --git a/framework/lua/lib/mhfu_port.lua b/framework/lua/lib/mhfu_port.lua new file mode 100644 index 00000000..14a36fcd --- /dev/null +++ b/framework/lua/lib/mhfu_port.lua @@ -0,0 +1,1118 @@ +-- mhfu_port: the ported-monster runtime. It loads a port's assets into a quest and drives the +-- monster so that the move it executes and the clip on screen are the same thing. +-- +-- A big monster runs on two channels: the executor's action id picks the CLIP, the behaviour pair +-- (ENTITY.MAIN_STATE, SUB_STATE) picks the MOVE, whose handler owns hitbox and damage. A port's +-- clips sit in the host's slots by position, not meaning, so a port declares the mapping +-- +-- moves = { charge = { main = 3, sub = 6, clip = "charge", after = "skid", hold_max = 8 }, +-- skid = { main = 0, sub = 3, clip = "stop" } } +-- +-- and port:play("charge") enters the pair and latches the port's clip in one call. +-- +-- A mod requires the library at its top level and registers a setup function, which runs at once +-- and again after a library reload; registering the same name again replaces it: +-- +-- local port = require("mhfu_port") +-- port.mod("my_mod", function(P) +-- local m = P.define{ ... } +-- m:brain(function(s) ... end) +-- end) +-- +-- The library owns the one global mhfu_tick; a mod never defines it and uses m:brain(fn). The +-- tick is 2 Hz (every 5th pass of lua_host's 10 Hz worker; the executor hook fires only on a new +-- dispatch, ~0.5/s), so a brain's thresholds absorb 500 ms of travel, ~650 units in a charge. +-- +-- More: framework/README.md (Lua mods); a port's clips and moves: ports/README.md. + +if (mhfu.api_version or 0) < 1 then + error("mhfu_port needs mhfu.api_version >= 1, this framework has " + .. tostring(mhfu.api_version or 0)) +end + +local P = package.loaded.mhfu_port or {} +P.ports = {} -- name -> port handle, rebuilt on a library reload +P._mods = P._mods or {} -- name -> setup fn, kept so a library reload rebuilds P.ports +P._once = P._once or {} -- side effects that happen once per boot + +-- ------------------------------------------------------------- offsets +-- Named here so a brain never needs a raw offset. +local E = mhfu.addr.ENTITY +local OFF_POS = E.POSITION +local OFF_YAW = E.YAW -- the rotation control: the matrix is rebuilt from it +local OFF_MAIN = E.MAIN_STATE +local OFF_SUB = E.SUB_STATE +local OFF_PREV_MAIN = E.PREV_MAIN -- act_set stores the outgoing pair here; handlers +local OFF_PREV_SUB = E.PREV_SUB -- read it on transitions +local OFF_PHASE = E.PHASE -- per-action phase cursor, 3 bytes +local OFF_SECTION = E.SECTION +local OFF_HP = E.HP -- current HP; MAX_HP is only the clamp +local OFF_MAX_HP = E.MAX_HP +local OFF_HZ_STATE = E.HITZONE_STATE +local OFF_FLAGS = E.FLAGS -- 0x8000 visible; bit 0x20 tracks HITZONE_STATE +local OFF_FREEZE = E.FREEZE_GATE +-- who the monster has committed to, not whether it has noticed anyone (see port_state) +local OFF_TARGET = E.TARGET +local OFF_ACQUIRED = E.TARGET_ACQUIRED +local FREEZE_BITS = 0x100 | 0x10000 +-- a move whose phase byte has not moved for this many ticks and that declares neither `after` +-- nor `hold_max` is logged as parked, once per play +local PARKED_TICKS = 10 + +-- The player's world position is the combat entity's transform row; mhfu.player_pos() reads the +-- camera eye. +local PLAYER_ENT = mhfu.addr.PLAYER_ENTITY +local PLAYER_XYZ = E.TRANSLATION + +-- ------------------------------------------------------------- floats +-- Use mhfu.read_f32/write_f32. The hand decoder scripts copy, `if u >= 0x80000000 then sign = +-- -1.0 ...`, is always true where lua_Integer is 32 bits (the PSP) and returns a clean sign flip: +-- invisible in a distance, a teleport to the mirror image once a position is written back. This +-- is the fixed form, kept only for the spawn cross-check. +local function u32_to_float(u) + local sign = ((u & 0x80000000) ~= 0) and -1.0 or 1.0 + local exp, mant = (u >> 23) & 0xFF, u & 0x7FFFFF + if exp == 0 then return sign * mant * (2.0 ^ -149) end + if exp == 255 then return sign * math.huge end + return sign * (1.0 + mant * (2.0 ^ -23)) * (2.0 ^ (exp - 127)) +end +local function rf(a) return mhfu.read_f32(a) end +local function wf(a, v) mhfu.write_f32(a, v) end + +-- math.atan2 is the pre-5.3 spelling +---@diagnostic disable-next-line: deprecated +local atan2 = math.atan2 or math.atan + +-- ------------------------------------------------------------- logging +-- mhfu.log takes one string (extra args vanish). Lines queue here and only the tick writes them: +-- lines logged at load or from an event callback go missing from framework.log. The queue lives +-- on P, so handlers registered before a library reload still reach it. +P._pend = P._pend or {} +local PEND = P._pend +local function log(fmt, ...) + PEND[#PEND + 1] = (select("#", ...) == 0) and fmt or string.format(fmt, ...) +end +P.log = log + +local function drain() + local n = #PEND + for i = 1, n do mhfu.log(PEND[i]); PEND[i] = nil end +end + +-- ------------------------------------------------------------- native seams +-- em_vhook's seams, live once it has latched the monster's vtable (mhfu.em_installed()). Then +-- play() enters a pair through the engine's own enter-action on the game thread, provisioned +-- (mhfu.em_request); a move's `claim` is entered in place of the host brain's pairs in its set +-- (mhfu.em_substitute: the host decides when, the port what); port:rule{} is a 30 Hz trigger with +-- no Lua in the loop (mhfu.em_rule). The latch happens on the spawn event that binds port.ent, so +-- claims and rules go in on the first tick after it (_arm_native). Without the seam, act_set below +-- is the fallback and the log says so once. +local EM_ANY = mhfu.EM_ANY or 0xFE +local EM_UNLIMITED = mhfu.EM_UNLIMITED or -1 +local MAX_SUBS, MAX_RULES = 4, 4 + +local function native_ready() + return mhfu.em_installed ~= nil and mhfu.em_installed() == true +end +P.native_ready = native_ready + +--- The seam's counters, or nil without it. `ring` is the last 8 enter-action dispatches (oldest +--- first: main, sub, mode, subst), what to read when a request or a substitution did not land. +function P.native_status() + if mhfu.em_status == nil then return nil end + return mhfu.em_status() +end + +local function ring_text(st) + if not st or not st.ring then return "?" end + local t = {} + for _, e in ipairs(st.ring) do + t[#t + 1] = string.format("(%d,%d,m%d)%s", e.main, e.sub, e.mode, + e.subst ~= 0 and "*" or "") + end + return table.concat(t, " ") +end + +--- A move's `claim` as a main-state bitmask + sub: `{ main = 1 }`, `{ main = {0, 1} }`, +--- `{ main = 1, sub = 7 }`. Returns mask, sub (EM_ANY when none). +local function claim_of(mv) + local c = mv.claim + if c == nil then return nil end + if type(c) == "number" then c = { main = c } end + local mains = c.main + if type(mains) == "number" then mains = { mains } end + local mask = 0 + for _, m in ipairs(mains or {}) do mask = mask | (1 << m) end + if mask == 0 then return nil end + return mask, c.sub or EM_ANY +end + +-- ------------------------------------------------------------- act_set +-- The engine's act_set as plain memory writes, the fallback while the seam is not live. It runs a +-- complete, damaging move, but unprovisioned: the engine's way in runs the per-main translator +-- first (the charge's run budget), so a pair forced here parks in its last phase with its hitbox +-- spent, and `after`/`hold_max` end it. It does the unconditional part of the engine's cursor +-- reset, the minimum a handler needs to run from its first phase. +-- +-- Pulse it, never per tick: rewriting the pair every tick restarts the move before its hitbox +-- frames, and repeated forcing sets the freeze bits that halt the AI tick, so each pulse clears +-- them. +local function act_set(ent, main, sub) + mhfu.write_u8(ent + OFF_PREV_MAIN, mhfu.read_u8(ent + OFF_MAIN)) + mhfu.write_u8(ent + OFF_PREV_SUB, mhfu.read_u8(ent + OFF_SUB)) + mhfu.write_u8(ent + OFF_MAIN, main) + mhfu.write_u8(ent + OFF_SUB, sub) + mhfu.write_u8(ent + OFF_PHASE, 0) + mhfu.write_u8(ent + OFF_PHASE + 1, 0) + mhfu.write_u8(ent + OFF_PHASE + 2, 0) + local g = mhfu.read_u32(ent + OFF_FREEZE) + if (g & FREEZE_BITS) ~= 0 then mhfu.write_u32(ent + OFF_FREEZE, g & ~FREEZE_BITS) end +end +P.act_set = act_set + +-- ------------------------------------------------------------- Port handle +local Port = {} +Port.__index = Port + +--- Declare a ported monster. +-- +-- name identifier, also the hot-reload key +-- species host species id the port rides on (mhfu.MON_TIGREX) +-- pac ported PAC filename under the framework's inject/ directory +-- orig the original PAC it replaces, same directory +-- fid cosmetic file id for the injector (matching is by content) +-- replace list of quest monster ids to swap for `species` +-- clips name -> executor a1, the port's own animation vocabulary +-- moves name -> { main, sub, clip }: which host behaviour runs and which of the port's +-- clips is shown while it does. `claim = { main = 1 }` (or `{ main = {0, 1}, sub = 7 }`) +-- enters this move in place of every host-brain pair in that set, so a port with one +-- attack rides the host's whole attack timing. +-- +-- Returns a port handle. Safe to call again (hot reload): the injector is armed once per boot. +function P.define(spec) + -- a redefine keeps the bound entity: the spawn event that bound it will not fire again, and + -- ent = 0 would stop the brain. The rest of the state resets. + local prev = P.ports[spec.name] + local self = setmetatable({ + name = spec.name, + species = spec.species, + clips = spec.clips or {}, + moves = spec.moves or {}, + replace = spec.replace or {}, + ent = prev and prev.ent or 0, + clip = nil, -- currently latched executor a1, nil = hands off + move = nil, -- currently scripted move name + pinned = nil, -- {x, y, z} while the coordinate lock is on + -- how many ticks the last scripted move survived: a pair whose handler declines the + -- situation ends on its first tick, and counting is the only way a brain finds out + last_move = nil, last_move_ticks = 0, + slip = 0, -- units the pin had to correct on the last tick + _brain = nil, + _by_pair = {}, -- main*256+sub -> move name, for pairs the ENGINE enters + _refused = {}, -- move name -> refusals, so the log says it once + _rules = {}, -- Port:rule{} declarations, installed by _arm_native + _native_armed = false, + _req = nil, -- a play() issued through the seam, until it lands + }, Port) + -- declared pairs the engine enters on its own get the port's clip too (the action hook paints + -- them), so the mapping holds whoever picked the move; first name wins for a pair declared twice + local names = {} + for n in pairs(self.moves) do names[#names + 1] = n end + table.sort(names) + for _, n in ipairs(names) do + local mv = self.moves[n] + local k = (mv.main or 0) * 256 + (mv.sub or 0) + if self._by_pair[k] == nil then self._by_pair[k] = n end + end + + if spec.pac and not P._once["inject:" .. spec.name] then + P._once["inject:" .. spec.name] = true + local dir = spec.inject_dir or "ms0:/PSP/PLUGINS/mhfu_framework/inject" + local ok = mhfu.inject_relocate(spec.fid, dir .. "/" .. spec.pac, + dir .. "/" .. spec.orig) + log("[port:%s] inject_relocate %s fid=%d '%s'", spec.name, + ok and "OK" or "FAILED", spec.fid, spec.pac) + end + + P.ports[spec.name] = self + return self +end + +--- Register the per-tick brain. `fn(state)` where state carries the monster, +--- the player, the distance between them and the live behaviour pair. +function Port:brain(fn) self._brain = fn; return self end + +--- Run a declared move: enter the behaviour pair AND latch the port's clip. +--- +--- Refuses the same move again within `min_gap` ticks: re-pulsing the moment a move ends is a +--- per-tick force, which restarts the move before its hitbox frames and sets the freeze bits. +--- +--- Refuses a pair that is already running, ours or the engine's: entering it zeroes the phase +--- cursor and restarts the action (the clip, the once-per-entry hitbox, the engine's own chain), +--- so a brain waits for the move to end (`s.move == nil`). `opts.force = true` restarts anyway, +--- for debugging; `opts.raw = true` writes the cells by hand even with the seam live (debugging +--- the seam itself). +function Port:play(move_name, min_gap, opts) + local mv = self.moves[move_name] + if not mv then log("[port:%s] no such move '%s'", self.name, tostring(move_name)); return false end + if self.ent == 0 then return false end + min_gap = min_gap or 2 + if self._played == move_name and (self._tick - (self._played_at or -99)) < min_gap then + return false + end + local live_main, live_sub = mhfu.read_u8(self.ent + OFF_MAIN), mhfu.read_u8(self.ent + OFF_SUB) + if live_main == mv.main and live_sub == mv.sub and not (opts and opts.force) then + local n = (self._refused[move_name] or 0) + 1 + self._refused[move_name] = n + if n == 1 or n % 20 == 0 then + log("[port:%s] play('%s') refused: (%d,%d) is already running%s — writing it " + .. "again restarts it from phase 0 (the hitbox spawns once per ENTRY). Let it " + .. "end (after=/hold_max=), or play(name, gap, {force=true}) for a debug restart.%s", + self.name, move_name, mv.main, mv.sub, + self.move == move_name and " (ours)" or " (the engine's own)", + n > 1 and string.format(" [x%d]", n) or "") + end + return false + end + self.move, self._played, self._played_at = move_name, move_name, self._tick + self._phase_seen, self._phase_ticks, self._parked = nil, 0, false + self._entered = { mv.main, mv.sub } + self.clip = mv.clip and self.clips[mv.clip] or mv.anim + -- With the seam live nothing is written here: the enter-action runs on the next AI frame + -- (<= 33 ms) and provisions the pair. The clip is latched first because the dispatch that + -- opens the move happens in that frame; `_req` checks the landing on the next tick. + if native_ready() and not (opts and opts.raw) then + local st = P.native_status() + self._req = { name = move_name, at = self._tick, done = st and st.req_done or 0, + main = mv.main, sub = mv.sub } + mhfu.em_request(mv.main, mv.sub, mv.mode or 0) + else + if not P._once.raw_warned and mhfu.em_installed == nil then + P._once.raw_warned = true + log("[port] this framework has no em_vhook seam: pairs are written by hand " + .. "(unprovisioned — a forced charge parks; after=/hold_max= end it)") + end + act_set(self.ent, mv.main, mv.sub) + end + -- The latch covers one dispatch, the one that opens the move. One pair runs a sequence of + -- executor dispatches, and overriding each restarts the clip from frame 0, so no animation + -- would play to its end. `latch = ` on a move buys more. + self._clip_uses = mv.latch or 1 + return true +end + +--- Latch a clip on the animation channel alone, with no behaviour write: the instrument under +--- play(), for asking which clip is in slot N of this build and whether it plays. It lands on +--- the next executor dispatch (a new action, ~0.5/s); `uses` defaults to 1 for the reason play() +--- latches once. Not for shipping mods: a clip with no behaviour under it is the clip/behaviour +--- mismatch this library removes. +function Port:latch(a1, uses) + if self.ent == 0 then return false end + self.clip, self._clip_uses = a1, uses or 1 + return true +end + +--- The declared chain: `mv.after`, entered now. Returns true when a next link +--- was taken. If the engine already moved him INTO that pair (its own hand-off +--- landed where the declaration points) the move is adopted rather than +--- re-written — writing it would restart what the engine just started. +function Port:_walk_after(mv, ended, why) + local nxt = mv.after + if not nxt then return false end + local nm = self.moves[nxt] + if not nm then + log("[port:%s] move '%s' declares after='%s', which is not a declared move", + self.name, ended, tostring(nxt)) + return false + end + local lm, ls = mhfu.read_u8(self.ent + OFF_MAIN), mhfu.read_u8(self.ent + OFF_SUB) + if lm == nm.main and ls == nm.sub then + self.move, self._played, self._played_at = nxt, nxt, self._tick + self._phase_seen, self._phase_ticks, self._parked = nil, 0, false + self._entered = { nm.main, nm.sub } + self.clip = nm.clip and self.clips[nm.clip] or nm.anim + self._clip_uses = nm.latch or 1 + log("[port:%s] '%s' %s -> after='%s': the engine is already in (%d,%d), adopted", + self.name, ended, why, nxt, lm, ls) + return true + end + if self:play(nxt) then + log("[port:%s] '%s' %s -> after='%s' (%d,%d)", self.name, ended, why, nxt, + nm.main, nm.sub) + return true + end + return false +end + +--- Hand both channels back to the engine's own AI. +function Port:release() + self.move, self.clip, self._played, self._clip_uses = nil, nil, nil, 0 + self._req, self._entered = nil, nil + self:unpin() +end + +--- A native 30 Hz brain rule, evaluated every frame by the seam's AI-step stub with no Lua in +--- the loop: +--- +--- port:rule{ from = "lunge", -- a move name, or { main = 1, sub = 4 }, +--- from_main = { 0, 1 }, -- ...or a set of main states (any sub) +--- min_frames = 15, -- the pair must have stood this long +--- dist = { 250, 1e9 }, -- player XZ distance window [lo, hi) +--- receding = true, -- only while the gap is GROWING +--- closing = false, -- only while it is SHRINKING +--- play = "lunge_stop", -- the move to enter (its main, sub) +--- mode = 0, cooldown = 30, count = mhfu.EM_UNLIMITED } +--- +--- Fires at most once per entry into `from` (the pair changes when it fires), +--- then `cooldown` frames must pass. Up to 4 rules per port; declared in the +--- mod's setup and installed when the seam is live. Without the seam the rule +--- is inert and logged as such — the 2 Hz brain is the fallback. +function Port:rule(spec) + if #self._rules >= MAX_RULES then + log("[port:%s] rule ignored: the seam holds %d", self.name, MAX_RULES) + return self + end + local r = { mask = 0, sub = EM_ANY, min_frames = spec.min_frames or 0, + dist_lo = spec.dist and spec.dist[1] or 0, + dist_hi = spec.dist and spec.dist[2] or 1.0e9, + receding = spec.receding and true or false, + closing = spec.closing and true or false, + mode = spec.mode or 0, cooldown = spec.cooldown or 0, + count = spec.count or EM_UNLIMITED, play = spec.play, label = spec.label } + local from = spec.from + if type(from) == "string" then + local mv = self.moves[from] + if not mv then log("[port:%s] rule: no such move '%s'", self.name, from); return self end + r.mask, r.sub = 1 << mv.main, mv.sub + elseif type(from) == "table" then + r.mask, r.sub = 1 << (from.main or 0), from.sub or EM_ANY + end + local fm = spec.from_main + if type(fm) == "number" then fm = { fm } end + for _, m in ipairs(fm or {}) do r.mask = r.mask | (1 << m) end + local to = self.moves[spec.play or ""] + if not to then + log("[port:%s] rule: play='%s' is not a declared move", self.name, tostring(spec.play)) + return self + end + r.to_main, r.to_sub = to.main, to.sub + if r.mask == 0 then + log("[port:%s] rule -> '%s': no `from` pair, ignored", self.name, spec.play) + return self + end + self._rules[#self._rules + 1] = r + self._native_armed = false -- (re)install on the next tick + return self +end + +--- Install every claim and rule on the seam. Runs on the first tick the seam +--- is live for this port, and again after a redefine (hot reload). Idempotent: +--- the table is rewritten slot by slot, unused slots cleared. +function Port:_arm_native() + self._native_armed = true + local names = {} + for n, mv in pairs(self.moves) do if claim_of(mv) then names[#names + 1] = n end end + table.sort(names) + local slot = 0 + for _, n in ipairs(names) do + local mv = self.moves[n] + local mask, sub = claim_of(mv) + ---@cast mask integer + ---@cast sub integer + if slot < MAX_SUBS then + mhfu.em_substitute(slot, mask, sub, mv.main, mv.sub, EM_UNLIMITED) + log("[port:%s] claim: host enter-actions with main in 0x%02X%s -> '%s' (%d,%d), standing", + self.name, mask, sub == EM_ANY and "" or string.format(" sub %d", sub), + n, mv.main, mv.sub) + else + log("[port:%s] claim on '%s' ignored: the seam holds %d", self.name, n, MAX_SUBS) + end + slot = slot + 1 + end + for k = slot, MAX_SUBS - 1 do mhfu.em_substitute(k, 0, EM_ANY, 0, 0, 0) end + for i = 1, MAX_RULES do + local r = self._rules[i] + if r then + mhfu.em_rule(i - 1, { from_mask = r.mask, from_sub = r.sub, to_main = r.to_main, + to_sub = r.to_sub, mode = r.mode, min_frames = r.min_frames, + dist_lo = r.dist_lo, dist_hi = r.dist_hi, receding = r.receding, + closing = r.closing, cooldown = r.cooldown, count = r.count }) + log("[port:%s] rule %d: main 0x%02X%s >=%d frames d[%d,%s)%s%s -> '%s' (%d,%d)", + self.name, i, r.mask, r.sub == EM_ANY and "" or (" sub " .. r.sub), + r.min_frames, math.floor(r.dist_lo), + r.dist_hi >= 1e9 and "inf" or tostring(math.floor(r.dist_hi)), + r.receding and " receding" or "", r.closing and " closing" or "", + r.play, r.to_main, r.to_sub) + else + mhfu.em_rule(i - 1, nil) + end + end +end + +--- Point the monster at (x, z). Writes ENTITY.YAW only, so the engine's own rotator renders the +--- turn. The angle is atan2(dx, dz), the engine's convention, packed 0..0xFFFF over 0..2pi. The +--- cell is obeyed exactly, so a wrong heading is the formula, not a contested write. +function Port:face(x, z) + if self.ent == 0 then return end + local mx = rf(self.ent + OFF_POS) + local mz = rf(self.ent + OFF_POS + 8) + local hw = math.floor(atan2(x - mx, z - mz) * 32768.0 / math.pi) % 65536 + mhfu.entity_set_yaw(self.ent, hw) +end + +--- Lock the monster's XZ where it stands. Y is left to the engine, which drives it to the floor +--- every frame; fighting that sinks him. +--- +--- The lock rewrites the position at 2 Hz against a per-frame engine, so it always undoes +--- something: ~45 units a tick over a pair the engine dwells in, 500+ over a pursuit state, which +--- shows as a slide and snap-back. No pair is truly stationary, so the pin stays. It is a per-tick +--- write, acceptable only because it is conditional and scoped to one brain phase; unpin logs the +--- total, and per tick it should be near 45, not 500. +function Port:pin() + if self.ent == 0 or self.pinned then return end + self.pinned = { rf(self.ent + OFF_POS), + rf(self.ent + OFF_POS + 4), + rf(self.ent + OFF_POS + 8) } +end +function Port:unpin() + if self.pinned and (self._slip_n or 0) > 0 then + log("[port:%s] pin released after correcting %d ticks, %d units total", + self.name, self._slip_n, math.floor(self._slip_sum or 0)) + end + self.pinned, self._slip_n, self._slip_sum = nil, 0, 0 +end + +function Port:hold_pin() + local p = self.pinned + if not p or self.ent == 0 then return 0 end + local x = rf(self.ent + OFF_POS) + local z = rf(self.ent + OFF_POS + 8) + local dx, dz = x - p[1], z - p[3] + local slip = math.floor(math.sqrt(dx * dx + dz * dz)) + if slip > 0 then + wf(self.ent + OFF_POS, p[1]) + wf(self.ent + OFF_POS + 8, p[3]) + end + return slip +end + +-- ------------------------------------------------------------- hit tables +-- The port's own hurtboxes, hitzones and attacks, written into the game. The editor exports a +-- port's [[hurtbox]] / [[hitzone]] as a generated `_hit.lua` that calls P.hit(name, tbl). +-- +-- Both hang off the species row (SPECIES_TABLE + species * SPECIES.STRIDE): HURTBOX_SET is the +-- HIT_VOLUME set the species walks (the overlay holds several and only this pointer picks one), +-- HITZONE_STATES points at its hitzone grids. Volumes go in place over the original records, then +-- a sentinel: shorter is fine, longer is not (the bytes past the original sentinel are someone +-- else's), so the original count, measured on first contact, is the cap. Nothing is relocated +-- and no pointer rewritten. +-- +-- Species data is map-wide per species id: as a replace the port is the only one of its species +-- in the quest, but beside a native of the same species this re-skins the native too. +-- +-- Applied once the entity is live in-area, re-checked every tick against one record and one grid +-- byte, and re-applied with a log line if either changed (an overlay reload shows up here). +-- +-- Attacks go the other way: a handler spawns an attack by id, and ATTACK_RECORD `id` at +-- attack_tables.records names a volume set (VOLUME_SET), reached through the overlay's pointer +-- table at attack_tables.volumes + set*4; both are the species overlay's static addresses, from +-- the export. Each authored set goes in place over the host set of that index, then a sentinel; +-- each `attacks` entry writes only the levers it names. The addresses are not handed over by the +-- engine, so a set is written only if its live record count equals the exported `cap` on first +-- contact; otherwise it is left alone with a log line. +local HV = mhfu.addr.HIT_VOLUME +local SPECIES_TABLE = mhfu.addr.SPECIES_TABLE +local SPECIES_STRIDE = mhfu.addr.SPECIES.STRIDE +local F_SPHERES = mhfu.addr.SPECIES.HURTBOX_SET +local F_STATES = mhfu.addr.SPECIES.HITZONE_STATES +local REC = HV.SIZE +local GRID_BLOCK = mhfu.addr.HITZONE_GRID.SIZE +local GRID_ROWS, GRID_COLS = 7, 10 +local SENTINEL = 0xFFFF +local MAX_RECORDS = 512 -- a walk bound; the longest set in the game is 49 +local ATK_REC = mhfu.addr.ATTACK_RECORD.SIZE +local ATK_POWER, ATK_ELEMENT, ATK_VOLUME = mhfu.addr.ATTACK_RECORD.POWER, + mhfu.addr.ATTACK_RECORD.ELEMENT, mhfu.addr.ATTACK_RECORD.VOLUME_SET + +P._hit = P._hit or {} -- port name -> table, from the generated module + +--- Register a port's hit tables. `tbl` = { species, id, volumes = {{bone, shape, +--- row, part, flags, radius, ax, ay, az, bx, by, bz}, ...} | nil, grid = {{row*7} +--- x states} | nil }. Keyed by PORT name so the data module and the brain module +--- can load in either order; the tick joins them. +function P.hit(name, tbl) + P._hit[name] = tbl + local port = P.ports[name] + if port then port._hit_id = nil end -- a re-export applies on the next tick + local nsets = 0 + if tbl.attack_sets then for _ in pairs(tbl.attack_sets) do nsets = nsets + 1 end end + log("[port:%s] hit tables registered: %s volume(s), %s grid state(s), %d attack " + .. "set(s), %s attack record(s), id %s", + name, tbl.volumes and #tbl.volumes or "no", tbl.grid and #tbl.grid or "no", + nsets, tbl.attacks and #tbl.attacks or "no", tostring(tbl.id)) +end + +local function species_row(port) + return SPECIES_TABLE + port.species * SPECIES_STRIDE +end + +local function write_record(at, r) + mhfu.write_u16(at + HV.BONE, r[1]) + mhfu.write_u16(at + HV.SHAPE, r[2]) + mhfu.write_u16(at + HV.HITZONE_ROW, r[3]) + mhfu.write_u16(at + HV.PART, r[4]) + mhfu.write_u32(at + HV.FLAGS, r[5]) + wf(at + HV.RADIUS, r[6]) + wf(at + HV.OFFSET_A, r[7]); wf(at + HV.OFFSET_A + 4, r[8]); wf(at + HV.OFFSET_A + 8, r[9]) + wf(at + HV.OFFSET_B, r[10]); wf(at + HV.OFFSET_B + 4, r[11]); wf(at + HV.OFFSET_B + 8, r[12]) +end + +local function write_sentinel(at) + for i = 0, 3 do mhfu.write_u16(at + i * 2, SENTINEL) end + for i = 8, REC - 4, 4 do mhfu.write_u32(at + i, 0) end +end + +--- The live address of attack volume set `set`, through the overlay's pointer +--- table, or nil (+why) when the export names a set the table does not have. +local function attack_set_base(tbl, set) + local at = tbl.attack_tables + if not at or not at.volumes then return nil, "no attack_tables in the export" end + if at.n_sets and set >= at.n_sets then + return nil, string.format("set %d but the host has %d", set, at.n_sets) + end + local base = mhfu.read_u32(at.volumes + set * 4) + if base == 0 or not mhfu.mem_valid(base) then + return nil, string.format("set %d pointer 0x%08X invalid", set, base) + end + return base +end + +--- Sorted set indices, so the log and the writes are in one order every time. +local function attack_set_indices(tbl) + local idx = {} + if tbl.attack_sets then + for k in pairs(tbl.attack_sets) do idx[#idx + 1] = k end + end + table.sort(idx) + return idx +end + +--- Does the live table still carry what we wrote? One record and one byte — +--- cheap enough for every tick, specific enough to catch a reload. +local function hit_intact(port, tbl) + local row = species_row(port) + if tbl.volumes then + local base = mhfu.read_u32(row + F_SPHERES) + if base == 0 then return false end + local n = math.min(#tbl.volumes, port._hit_cap or #tbl.volumes) + if n == 0 then + if mhfu.read_u16(base) ~= SENTINEL then return false end + else + local r = tbl.volumes[1] + if mhfu.read_u16(base) ~= r[1] then return false end + if math.abs(rf(base + HV.RADIUS) - r[6]) > 0.01 then return false end + if mhfu.read_u16(base + n * REC) ~= SENTINEL then return false end + end + end + if tbl.grid and tbl.grid[1] then + local stt = mhfu.read_u32(row + F_STATES) + if stt == 0 then return false end + local blk = mhfu.read_u32(stt) + if blk == 0 or mhfu.read_u8(blk + 1) ~= tbl.grid[1][1][2] then return false end + end + for _, set in ipairs(attack_set_indices(tbl)) do + local spec = tbl.attack_sets[set] + local base = attack_set_base(tbl, set) + if not base then return false end + local cap = (port._atk_caps and port._atk_caps[set]) or spec.cap or #spec.volumes + local n = math.min(#spec.volumes, cap) + if n == 0 then + if mhfu.read_u16(base) ~= SENTINEL then return false end + else + local r = spec.volumes[1] + if mhfu.read_u16(base) ~= r[1] then return false end + if math.abs(rf(base + HV.RADIUS) - r[6]) > 0.01 then return false end + if mhfu.read_u16(base + n * REC) ~= SENTINEL then return false end + end + end + if tbl.attacks and tbl.attack_tables and tbl.attack_tables.records then + for _, a in ipairs(tbl.attacks) do + local rec = tbl.attack_tables.records + a.id * ATK_REC + if a.power and mhfu.read_u8(rec + ATK_POWER) ~= a.power then return false end + if a.element and mhfu.read_u8(rec + ATK_ELEMENT) ~= a.element then return false end + if a.volume and mhfu.read_u8(rec + ATK_VOLUME) ~= a.volume then return false end + end + end + return true +end + +local function hit_apply(port, tbl) + local row = species_row(port) + local wrote = {} + if tbl.volumes then + local base = mhfu.read_u32(row + F_SPHERES) + if base == 0 or not mhfu.mem_valid(base) then + return false, string.format("no set pointer at 0x%08X", row + F_SPHERES) + end + -- the cap is the original count, measured once per boot (P._once): after a library reload, + -- measuring again would count our shorter table and shrink the cap + local capkey = "hitcap:" .. tostring(port.species) + if not P._once[capkey] then + local n = 0 + while n < MAX_RECORDS and mhfu.read_u16(base + n * REC) ~= SENTINEL do + n = n + 1 + end + P._once[capkey] = n + log("[port:%s] host set 0x%08X holds %d record(s) — the in-place cap", + port.name, base, n) + end + port._hit_cap = P._once[capkey] + local n = #tbl.volumes + if n > port._hit_cap then + log("[port:%s] %d volume(s) but only %d fit in place — TRUNCATED", + port.name, n, port._hit_cap) + n = port._hit_cap + end + for i = 1, n do write_record(base + (i - 1) * REC, tbl.volumes[i]) end + write_sentinel(base + n * REC) + wrote[#wrote + 1] = string.format("%d volume(s) @0x%08X", n, base) + end + if tbl.grid then + local stt = mhfu.read_u32(row + F_STATES) + if stt == 0 or not mhfu.mem_valid(stt) then + return false, string.format("no state table at 0x%08X", row + F_STATES) + end + -- the state count is stored nowhere: the pointer table sits right after + -- the last block it points at, so it is (table - first_block) / 0x48 + local b0 = mhfu.read_u32(stt) + local have = (stt - b0) // GRID_BLOCK + if have < 1 or have > 8 then + return false, string.format("state table 0x%08X -> 0x%08X: %d states?", + stt, b0, have) + end + local n = math.min(#tbl.grid, have) + if #tbl.grid ~= have then + log("[port:%s] grid: manifest has %d state(s), the species %d — writing %d", + port.name, #tbl.grid, have, n) + end + for s = 1, n do + local blk = mhfu.read_u32(stt + (s - 1) * 4) + for r = 1, GRID_ROWS do + local rowv = tbl.grid[s][r] + for c = 1, GRID_COLS do + mhfu.write_u8(blk + (r - 1) * GRID_COLS + (c - 1), rowv[c]) + end + end + end + wrote[#wrote + 1] = string.format("%d grid state(s) @0x%08X", n, b0) + end + -- attack sets, each in place over the host set of that index. The live count is measured once + -- per set (P._once, as for the hurtbox cap) and must equal the exported `cap`, or nothing is + -- written: the address is static, and a wrong count means a different overlay is loaded. + local sets = attack_set_indices(tbl) + if #sets > 0 then + port._atk_caps = port._atk_caps or {} + for _, set in ipairs(sets) do + local spec = tbl.attack_sets[set] + local base, why = attack_set_base(tbl, set) + if not base then return false, "attack " .. why end + local capkey = string.format("atkcap:%d:%d", port.species, set) + if not P._once[capkey] then + local n = 0 + while n < MAX_RECORDS and mhfu.read_u16(base + n * REC) ~= SENTINEL do + n = n + 1 + end + if spec.cap and n ~= spec.cap then + return false, string.format( + "attack set %d @0x%08X holds %d record(s), the export expected %d — " + .. "not the table the export was built against; NOT written", + set, base, n, spec.cap) + end + P._once[capkey] = n + log("[port:%s] attack set %d @0x%08X holds %d record(s) — the in-place cap", + port.name, set, base, n) + end + port._atk_caps[set] = P._once[capkey] + end + local n_sets, n_vols = 0, 0 + for _, set in ipairs(sets) do + local spec = tbl.attack_sets[set] + local base = attack_set_base(tbl, set) + local cap = port._atk_caps[set] + local n = #spec.volumes + if n > cap then + log("[port:%s] attack set %d: %d volume(s) but only %d fit in place — " + .. "TRUNCATED", port.name, set, n, cap) + n = cap + end + for i = 1, n do write_record(base + (i - 1) * REC, spec.volumes[i]) end + write_sentinel(base + n * REC) + n_sets, n_vols = n_sets + 1, n_vols + n + end + wrote[#wrote + 1] = string.format("%d attack set(s)/%d volume(s) via 0x%08X", + n_sets, n_vols, tbl.attack_tables.volumes) + end + if tbl.attacks and #tbl.attacks > 0 then + local at = tbl.attack_tables + if not at or not at.records then + return false, "attacks but no attack_tables.records in the export" + end + local n = 0 + for _, a in ipairs(tbl.attacks) do + if at.n_records and a.id >= at.n_records then + log("[port:%s] attack record %d but the host has %d — skipped", + port.name, a.id, at.n_records) + else + local rec = at.records + a.id * ATK_REC + if a.power then mhfu.write_u8(rec + ATK_POWER, a.power) end + if a.element then mhfu.write_u8(rec + ATK_ELEMENT, a.element) end + if a.volume then mhfu.write_u8(rec + ATK_VOLUME, a.volume) end + n = n + 1 + end + end + wrote[#wrote + 1] = string.format("%d attack record(s) @0x%08X", n, at.records) + end + return true, table.concat(wrote, ", ") +end + +--- Called from the tick for a live, in-area port. Applies on the first +--- opportunity and whenever the live bytes stop matching. +local function hit_tick(port) + local tbl = P._hit[port.name] + if not tbl then return end + if port._hit_id == tbl.id and hit_intact(port, tbl) then return end + local why = port._hit_id == tbl.id and "live table changed under us" + or (port._hit_id and "new export" or "first contact") + local ok, what = hit_apply(port, tbl) + if ok then + port._hit_id = tbl.id + log("[port:%s] HIT TABLES APPLIED (%s): %s id=%s", port.name, why, what, + tostring(tbl.id)) + else + port._hit_id = nil + if (port._hit_fail or 0) % 20 == 0 then + log("[port:%s] hit tables NOT applied: %s", port.name, tostring(what)) + end + port._hit_fail = (port._hit_fail or 0) + 1 + end +end + +-- ------------------------------------------------------------- events +-- Registered once per boot and dispatched to whatever ports exist, so a mod reload re-runs its +-- setup, rebuilds its ports and never stacks a handler. + +-- Every hit the port takes, with the amount. It fires from the 5 Hz monster poll on an HP drop, +-- so two hits within 200 ms arrive as one line with their sum. `st` is HITZONE_STATE, the grid the +-- hit went through (it follows bit 0x20 of FLAGS and flips mid-fight). Registered outside the +-- once block on purpose: the binding installs its trampoline once and replaces the closure, so a +-- library reload picks up a new format without stacking. +mhfu.on_bigmonster_damaged(function(ent, mtype, amount, hp, slot) + for _, port in pairs(P.ports) do + if port.ent == ent then + port._hits = (port._hits or 0) + 1 + log("[port:%s] HIT #%d -%d hp=%d st=%d f638=0x%X", port.name, port._hits, + amount, hp, mhfu.read_u8(ent + OFF_HZ_STATE), mhfu.read_u32(ent + OFF_FLAGS)) + end + end +end) + +if not P._once.events then +P._once.events = true + +mhfu.on_quest_targets_building(function(quest) + if quest == 0 then return end + for name, port in pairs(P.ports) do + for _, victim in ipairs(port.replace) do + if mhfu.quest_has(quest, victim) then + local ok = mhfu.quest_replace_monster(quest, victim, port.species) + log("[port:%s] swap %d -> %d %s", name, victim, port.species, + ok and "applied" or "FAILED") + end + end + end +end) + +mhfu.on_bigmonster_spawn(function(ent, mtype, slot, hp) + for name, port in pairs(P.ports) do + if mtype == port.species and port.ent == 0 then + port.ent = ent + log("[port:%s] SPAWN ent=0x%08X slot=%d hp=%d sec=%d pos=(%d,%d,%d)", + name, ent, slot, hp, mhfu.read_u16(ent + OFF_SECTION), + math.floor(rf(ent + OFF_POS)), math.floor(rf(ent + OFF_POS + 4)), + math.floor(rf(ent + OFF_POS + 8))) + -- the two decoders must agree; if they diverge, believe the debugger (see floats) + log("[port:%s] f32 check binding=%d hand=%d", name, + math.floor(mhfu.read_f32(ent + OFF_POS)), + math.floor(u32_to_float(mhfu.read_u32(ent + OFF_POS)))) + end + end +end) + +mhfu.on_bigmonster_death(function(ent) + for _, port in pairs(P.ports) do + if port.ent == ent then port.ent = 0; port:release() end + end +end) + +-- The alignment seam: the host handler has just asked the executor for its own clip; with a +-- scripted move active the port's is returned instead. nil abstains and the engine's choice stands. +mhfu.on_bigmonster_action(function(ctx) + for _, port in pairs(P.ports) do + if ctx.entity == port.ent then + if port.clip and (port._clip_uses or 0) > 0 then + port._clip_uses = port._clip_uses - 1 + if port.clip ~= ctx.action_id then + log("[port:%s] clip %d -> %d (move=%s)", port.name, ctx.action_id, + port.clip, tostring(port.move)) + end + return port.clip + end + -- no scripted move, but the engine entered a declared pair itself: its clip paints it for + -- as many dispatches as its latch says. No `move`, no `after`: the engine walks its chain. + if port.ent ~= 0 and next(port._by_pair) ~= nil then + local k = mhfu.read_u8(port.ent + OFF_MAIN) * 256 + mhfu.read_u8(port.ent + OFF_SUB) + local name = port._by_pair[k] + local mv = name and port.moves[name] + local clip = mv and (mv.clip and port.clips[mv.clip] or mv.anim) + if clip then + local paint = port._paint + if not paint or paint.key ~= k then + paint = { key = k, uses = mv.latch or 1 } + port._paint = paint + end + if paint.uses > 0 then + paint.uses = paint.uses - 1 + if clip ~= ctx.action_id then + log("[port:%s] clip %d -> %d (engine entered '%s' itself)", port.name, + ctx.action_id, clip, name) + end + return clip + end + end + end + end + end + return nil +end, 10) + +end -- P._once.events + +-- ------------------------------------------------------------- the tick +local g_tick = 0 + +local function port_state(port) + -- travelled since the last tick is a 2 Hz brain's only speed signal: ~650 units in a charge, + -- ~180 in the pursuit walk + local ent = port.ent + local mx = rf(ent + OFF_POS) + local my = rf(ent + OFF_POS + 4) + local mz = rf(ent + OFF_POS + 8) + local px = rf(PLAYER_ENT + PLAYER_XYZ) + local py = rf(PLAYER_ENT + PLAYER_XYZ + 4) + local pz = rf(PLAYER_ENT + PLAYER_XYZ + 8) + local sec, area = mhfu.read_u16(ent + OFF_SECTION), mhfu.get_area_index() + -- every section has its own world frame: positions from either side of a section change do + -- not subtract, so the first tick after one has no previous sample + local dist = math.sqrt((mx - px) ^ 2 + (mz - pz) ^ 2) + local last = port._last + local travelled, closing = 0, 0 + if last and last[3] == sec then + travelled = math.sqrt((mx - last[1]) ^ 2 + (mz - last[2]) ^ 2) + -- a "does he reach the hunter" projection uses closing, not travelled: a hunter walking in + -- shrinks the gap by both speeds. travelled says which pair is running. + closing = last[4] - dist + end + port._last = { mx, mz, sec, dist } + local target = mhfu.read_u32(ent + OFF_TARGET) + return { + travelled = travelled, closing = closing, + move = port.move, pinned = (port.pinned ~= nil), + slip = port.slip, + -- what the LAST scripted move achieved, so a brain can drop a pair the + -- engine refuses instead of re-issuing it forever + last_move = port.last_move, last_move_ticks = port.last_move_ticks, + since_play = g_tick - (port._played_at or -999), + phase = mhfu.read_u8(ent + OFF_PHASE), + port = port, ent = ent, tick = g_tick, + x = mx, y = my, z = mz, px = px, py = py, pz = pz, + dist = dist, + section = sec, area = area, same_section = (sec == area), + -- true on the first tick of a new frame: dist is readable, travelled is not + reframed = (last == nil or last[3] ~= sec), + -- true while the em_vhook seam drives play()/claims/rules + native = native_ready(), + -- has noticed a target and pursues it (the '!'), not full combat + engaged = mhfu.entity_engaged(ent), + -- who he has committed to: picked by a priority index where the Felyne outranks the hunter, + -- so it flickers between them up close and sits on the cat at range. A diagnostic, not a + -- gate: a brain gating on targets_player stutters. `acquired` is no combat signal either; + -- it reads 0 on an engaged native mid-attack. + target = target, targets_player = (target == PLAYER_ENT), + acquired = mhfu.read_u8(ent + OFF_ACQUIRED), + main = mhfu.read_u8(ent + OFF_MAIN), sub = mhfu.read_u8(ent + OFF_SUB), + hp = mhfu.read_u16(ent + OFF_HP), + max_hp = mhfu.read_u16(ent + OFF_MAX_HP), + hz_state = mhfu.read_u8(ent + OFF_HZ_STATE), + player_hp = mhfu.get_player_hp(), + } +end + +function mhfu_tick() + drain() + mhfu.paint_map() + g_tick = g_tick + 1 + + for _, port in pairs(P.ports) do + port._tick = g_tick + -- no entity but one of the species is live: adopt it (a missed spawn event, or a reload) + if port.ent == 0 then + local live = mhfu.entities_of_type(port.species) + if live and live[1] and live[1] ~= 0 then + port.ent = live[1] + log("[port:%s] adopted live entity 0x%08X (no spawn event)", port.name, port.ent) + end + end + if port.ent ~= 0 and mhfu.entity_alive(port.ent) then + -- a paint of an engine-entered pair is per entry: forget it once he has left that pair, + -- so his next native entry is painted too + if port._paint then + local k = mhfu.read_u8(port.ent + OFF_MAIN) * 256 + mhfu.read_u8(port.ent + OFF_SUB) + if k ~= port._paint.key then port._paint = nil end + end + -- the seam comes up on the spawn event; the first tick after it is where + -- the claims and rules go in (and again after a redefine) + if not port._native_armed and native_ready() then + if not P._once["native:" .. port.name] then + P._once["native:" .. port.name] = true + log("[port:%s] native seam live: play() enters through the engine's " + .. "enter-action (provisioned); claims and rules installed", port.name) + end + port:_arm_native() + end + -- a play() issued through the seam: did the engine take it? + if port._req then + local rq, st = port._req, P.native_status() + local lm, ls = mhfu.read_u8(port.ent + OFF_MAIN), mhfu.read_u8(port.ent + OFF_SUB) + if lm == rq.main and ls == rq.sub then + log("[port:%s] '%s' entered natively (%d,%d), provisioned", port.name, rq.name, lm, ls) + port._req = nil + elseif ls == rq.sub then + -- the translator's alternative main (em75 id 4: (1,4) or (2,4)) + log("[port:%s] '%s' entered natively as (%d,%d) — the translator's main for " + .. "id %d; tracking that pair", port.name, rq.name, lm, ls, rq.sub) + port._entered = { lm, ls } + port._req = nil + elseif st and st.req_done > rq.done and g_tick - rq.at >= 1 + and st.req_main == rq.main and st.req_sub == rq.sub then + -- it landed (the cells read our pair right after the call) and was over within the + -- tick, as a charge a 30 Hz rule ends does; the ended path below adopts or plays `after` + log("[port:%s] '%s' entered natively (%d,%d) and was over within the tick " + .. "(now (%d,%d))", port.name, rq.name, rq.main, rq.sub, lm, ls) + port._req = nil + elseif st and st.req_done > rq.done and g_tick - rq.at >= 1 then + -- issued (req_done moved) but the cells never showed it: the enter- + -- action declined it, or something re-entered in the same frame + log("[port:%s] '%s' (%d,%d) was requested and issued but the cells read " + .. "(%d,%d) — result after the call (%d,%d); last enter-actions: %s", + port.name, rq.name, rq.main, rq.sub, lm, ls, st.req_main, st.req_sub, + ring_text(st)) + port._req, port.move, port.clip, port._clip_uses = nil, nil, nil, 0 + port._entered = nil + elseif g_tick - rq.at >= 3 then + log("[port:%s] '%s' request never issued after %d ticks (req_done %s) — " + .. "is the seam still latched?", port.name, rq.name, g_tick - rq.at, + tostring(st and st.req_done)) + port._req, port.move, port.clip, port._clip_uses = nil, nil, nil, 0 + port._entered = nil + end + end + if port.move then + local mv = port.moves[port.move] + local lm, ls = mhfu.read_u8(port.ent + OFF_MAIN), mhfu.read_u8(port.ent + OFF_SUB) + local held = g_tick - (port._played_at or g_tick) + local em, es = mv and mv.main, mv and mv.sub + if port._entered then em, es = port._entered[1], port._entered[2] end + if port._req then + -- not landed yet (<= 1 tick): nothing to judge + elseif mv and (lm ~= em or ls ~= es) then + -- the pair is the ground truth: once the engine has left ours the move is over, and + -- its clip goes with it, or it would paint whatever the engine does next + log("[port:%s] move '%s' (%d,%d) ended after %d ticks -> (%d,%d)", port.name, + port.move, em, es, held, lm, ls) + local ended = port.move + port.last_move, port.last_move_ticks = ended, held + port.move, port.clip, port._clip_uses, port._entered = nil, nil, 0, nil + port:_walk_after(mv, ended, "ended") + elseif mv then + -- still standing. `hold_max` ends it from here; otherwise watch the + -- phase byte, because a pair whose phase stops moving has nothing + -- left to do and will not leave by itself. + if mv.hold_max and held >= mv.hold_max then + log("[port:%s] move '%s' held %d ticks (hold_max %d) on (%d,%d)", port.name, + port.move, held, mv.hold_max, lm, ls) + local ended = port.move + port.last_move, port.last_move_ticks = ended, held + port.move, port.clip, port._clip_uses, port._entered = nil, nil, 0, nil + if not port:_walk_after(mv, ended, "hold_max") then port:release() end + elseif not mv.after then + local phase = mhfu.read_u8(port.ent + OFF_PHASE) + if phase == port._phase_seen then + port._phase_ticks = (port._phase_ticks or 0) + 1 + else + port._phase_seen, port._phase_ticks = phase, 0 + end + if port._phase_ticks >= PARKED_TICKS and not port._parked then + port._parked = true + log("[port:%s] move '%s' PARKED: (%d,%d) phase %d unchanged for %d ticks. " + .. "A parked pair spawns nothing more — the engine provisions this pair " + .. "on its own way in (a run budget, a target) and act_set does not. " + .. "Declare after=/hold_max= on the move, or release().", + port.name, port.move, lm, ls, phase, port._phase_ticks) + end + end + end + end + if port.pinned then + port.slip = port:hold_pin() + -- one log line per 20 corrections, not per tick: the running total is what matters + port._slip_n = (port._slip_n or 0) + (port.slip > 4 and 1 or 0) + port._slip_sum = (port._slip_sum or 0) + port.slip + if port.slip > 40 and (port._slip_n == 1 or port._slip_n % 20 == 0) then + log("[port:%s] pin has corrected %d ticks, %d units total (last %d) t=%d", + port.name, port._slip_n, math.floor(port._slip_sum), port.slip, g_tick) + end + else + port.slip = 0 + end + -- the hit tables land once he is live in-area, and are re-checked here + if mhfu.get_screen_state() == 17 then + local ok, err = pcall(hit_tick, port) + if not ok then log("[port:%s] hit error: %s", port.name, tostring(err)) end + end + if port._brain then + local ok, err = pcall(port._brain, port_state(port)) + if not ok then log("[port:%s] brain error: %s", port.name, tostring(err)) end + end + elseif port.ent ~= 0 then + port.ent = 0; port:release() + end + end + drain() +end + +-- ------------------------------------------------------------- ready +-- port.mod keeps a setup by name (the same name replaces it) and runs it now. A library reload +-- resets P.ports, so every kept setup runs again to rebuild them. +local function run_setup(name, fn, why) + P._mods[name] = fn + local ok, err = pcall(fn, P) + log("[port] mod '%s' setup %s%s", name, ok and "ok" or ("FAILED: " .. tostring(err)), why) +end + +P.mod = function(name, fn) run_setup(name, fn, "") end + +for name, fn in pairs(P._mods) do run_setup(name, fn, " (relink)") end + +log("[port] mhfu_port runtime ready") + +return P diff --git a/framework/lua/meta/mhfu.d.lua b/framework/lua/meta/mhfu.d.lua new file mode 100644 index 00000000..840d5b52 --- /dev/null +++ b/framework/lua/meta/mhfu.d.lua @@ -0,0 +1,601 @@ +---@meta +-- The declaration of the mhfu API: tools/lua_api.py builds the registered table from it, so a +-- function exists in the game exactly when it is declared here. + +---The C bindings lua_host gives every mod, and what lua/lib/_prelude.lua adds to them. +---@class mhfu +---@field addr mhfu.addr every game address and struct offset, built on first read +mhfu = {} + +---The API's version, raised whenever a declaration here changes; a mod that needs something added +---in version N checks `mhfu.api_version >= N`. +mhfu.api_version = 1 + +-- log, memory and world state (bind_memory.cpp) ---------------------------------------------- + +---Appends a line to framework.log, cut at 255 bytes; lines logged outside an area are held until +---the game is in one. +---@param msg string +function mhfu.log(msg) end + +---Reads the u8 at addr, unchecked: an unmapped address crashes the game. +---@param addr integer +---@return integer +function mhfu.read_u8(addr) end + +---Reads the u16 at addr (2-aligned), unchecked. +---@param addr integer +---@return integer +function mhfu.read_u16(addr) end + +---Reads the u32 at addr (4-aligned), unchecked. lua_Integer is 32 bits, so a value from +---0x80000000 up is negative: test bits with `&`. +---@param addr integer +---@return integer +function mhfu.read_u32(addr) end + +---True if addr is in user RAM; the extra-RAM window, where clones live, is not. Check a pointer +---read from the game before following it. +---@param addr integer +---@return boolean +function mhfu.mem_valid(addr) end + +---Writes the low 8 bits of v at addr, unchecked. +---@param addr integer +---@param v integer +function mhfu.write_u8(addr, v) end + +---Writes the low 16 bits of v at addr (2-aligned), unchecked. +---@param addr integer +---@param v integer +function mhfu.write_u16(addr, v) end + +---Writes v at addr (4-aligned), unchecked; a code word takes only while the JIT is cold (screen +---state 1 or 4). +---@param addr integer +---@param v integer +function mhfu.write_u32(addr, v) end + +---Reads the f32 at addr (4-aligned), unchecked. +---@param addr integer +---@return number +function mhfu.read_f32(addr) end + +---Writes v as an f32 at addr (4-aligned), unchecked. +---@param addr integer +---@param v number +function mhfu.write_f32(addr, v) end + +---The screen-state id; its values are listed on mhfu.addr.SCREEN_STATE (17 = in an area). +---@return integer +function mhfu.get_screen_state() end + +---The visible map section, in the encoding entity_section uses. +---@return integer +function mhfu.get_area_index() end + +---Quest frames left, at 30 Hz. +---@return integer +function mhfu.get_quest_timer() end + +---The camera eye (mhfu.addr.CAM_VIEW_EYE), about 500 units off the hunter: not his own position. +---@return number x +---@return number y +---@return number z +function mhfu.player_pos() end + +---The hunter's current HP. +---@return integer +function mhfu.get_player_hp() end + +---Shows every big monster on the map; repeat at 2 Hz or faster to keep them shown. +function mhfu.paint_map() end + +-- entities, clones and collision nodes (bind_entity.cpp) ------------------------------------- +-- An entity is its pointer. The getters return 0 for a pointer outside RAM; the setters do nothing. + +---The entity in registry slot 0..20, or 0 if the slot is empty; monsters are in 1..20. +---@param slot integer +---@return integer ent +function mhfu.entity_at(slot) end + +---The entity's monster type (mhfu.MON_*). +---@param ent integer +---@return integer +function mhfu.entity_type(ent) end + +---Current HP. +---@param ent integer +---@return integer +function mhfu.entity_hp(ent) end + +---The size multiplier. +---@param ent integer +---@return number +function mhfu.entity_size(ent) end + +---Sets the size multiplier, in every copy the engine keeps of it. +---@param ent integer +---@param size number +function mhfu.entity_set_size(ent, size) end + +---True while ent is in the entity registry; says nothing about its HP. +---@param ent integer +---@return boolean +function mhfu.entity_alive(ent) end + +---World position. +---@param ent integer +---@return number x +---@return number y +---@return number z +function mhfu.entity_pos(ent) end + +---Moves ent, its transform's translation row with it. +---@param ent integer +---@param x number +---@param y number +---@param z number +function mhfu.entity_set_pos(ent, x, y, z) end + +---Facing: 0..0xFFFF is one turn, atan2(dx, dz). +---@param ent integer +---@return integer +function mhfu.entity_yaw(ent) end + +---Sets the facing (0..0xFFFF is one turn). +---@param ent integer +---@param yaw integer +function mhfu.entity_set_yaw(ent, yaw) end + +---The byte at ENTITY.ANIM_SPEED: it tracks the action playing, and 2 is part of the engage +---signature (entity_force_aggro); not a real AI state. +---@param ent integer +---@return integer +function mhfu.entity_ai_state(ent) end + +---Writes the byte entity_ai_state reads; the next action the engine installs overwrites it. +---@param ent integer +---@param state integer +function mhfu.entity_set_ai_state(ent, state) end + +---True while ent has noticed a target and pursues it (ENTITY.ENGAGE >= 0.5). +---@param ent integer +---@return boolean +function mhfu.entity_engaged(ent) end + +---Writes ENTITY.ENGAGE as 1.0 or 0.0; the engine rewrites it every frame. +---@param ent integer +---@param engaged boolean +function mhfu.entity_set_engaged(ent, engaged) end + +---Zeroes ENGAGE and both detection ranges; the engine never restores the ranges, so ent stays calm. +---@param ent integer +function mhfu.entity_calm(ent) end + +---The section the engine places ent in (get_area_index's encoding); lags a transition. +---@param ent integer +---@return integer +function mhfu.entity_section(ent) end + +---Sets the section the engine places ent in. +---@param ent integer +---@param section integer +function mhfu.entity_set_section(ent, section) end + +---Makes a swapped monster draw in section (pass get_area_index()) by setting what the visibility +---gate checks. Once, not every tick. +---@param ent integer +---@param section integer +function mhfu.entity_make_visible(ent, section) end + +---Engages ent on the point x, y, z as the engine does, for where its own target resolver finds +---nothing: pursuit vector, ENGAGE, ai state 2 and the acquired flag in one go. +---@param ent integer +---@param x number +---@param y number +---@param z number +function mhfu.entity_force_aggro(ent, x, y, z) end + +---Up to 16 live entities of one monster type, in registry order. +---@param species integer +---@return integer[] +function mhfu.entities_of_type(species) end + +---Clones src into a free registry slot, calm and sharing its model and overlay; 0 on failure. +---Needs `memory = 64` when partition RAM is short. A clone deals no damage until it has a node. +---@param src integer +---@return integer ent +function mhfu.entity_clone(src) end + +---Gives clone ent a collision node copied from a native's node template, so it can damage the +---hunter; 0 on failure. A nonzero uid becomes the node id (16 at most; a large one crashes). +---Once per clone. +---@param template integer +---@param ent integer +---@param uid integer +---@return integer node +function mhfu.node_clone(template, ent, uid) end + +---ent's collision node, or 0: without one it cannot deal damage. +---@param ent integer +---@return integer node +function mhfu.node_of(ent) end + +---True while node is reachable from the collision list's head. +---@param node integer +---@return boolean +function mhfu.node_linked(node) end + +---Re-inserts node into the collision list after a section change; true if it had to. +---@param node integer +---@return boolean +function mhfu.node_relink(node) end + +---Copies ent's position into node. +---@param node integer +---@param ent integer +function mhfu.node_sync(node, ent) end + +---Unlinks node; call before its clone goes away, since a dangling node crashes the game. +---@param node integer +function mhfu.node_detach(node) end + +---Anteka's monster id. +mhfu.MON_ANTEKA = 0x45 -- MHFU_MONSTER_ANTEKA +---Popo's monster id. +mhfu.MON_POPO = 0x46 -- MHFU_MONSTER_POPO +---Tigrex's monster id. +mhfu.MON_TIGREX = 0x4B -- MHFU_MONSTER_TIGREX +---Giadrome's monster id. +mhfu.MON_GIADROME = 0x4D -- MHFU_MONSTER_GIADROME + +-- the quest's big monsters (bind_quest.cpp) -------------------------------------------------- +-- quest is the handle on_quest_targets_building passes; the list is editable only in that event. + +---True if the quest lists species among its big monsters. +---@param quest integer +---@param species integer +---@return boolean +function mhfu.quest_has(quest, species) end + +---Retags monster from as to in place, so the engine loads to natively; false if from is missing +---or to's record layout is unknown (only Tigrex's is known). +---@param quest integer +---@param from integer +---@param to integer +---@return boolean +function mhfu.quest_replace_monster(quest, from, to) end + +---Adds species as another big monster in its own target group, which is what lets it deal +---damage; false once the groups are taken or for an unknown layout (only Tigrex's is known). +---@param quest integer +---@param species integer +---@param x? number spawn point; 0 or nil keeps the source record's +---@param z? number +---@return boolean +function mhfu.quest_add_monster(quest, species, x, z) end + +---How many big monsters the quest lists (8 at most). +---@param quest integer +---@return integer +function mhfu.quest_monster_count(quest) end + +---The monster id of the first big-monster record, or -1. +---@param quest integer +---@return integer +function mhfu.quest_first_monster(quest) end + +-- relocated overlays and model injection (bind_inject.cpp) ----------------------------------- +-- file_id is the engine's file id: the extracted file number + 1. + +---Loads a second em*.ovl at a fresh address, relocated, bss zeroed; once per session, and not +---wired to the engine. On failure returns nil and a negative error code (framework.log says why). +---@param path string +---@param run_inits? boolean run its static initializers +---@return integer? base the footprint's start +---@return integer load_or_err the relocated load address, or the error code +---@return integer? delta +function mhfu.load_relocated_overlay(path, run_inits) end + +---Replaces a big-monster PAC as the game loads it, with no disk edit: the PAC at path overwrites +---the raw buffer that byte-matches .orig (same size), again whenever the file changes. +---@param file_id integer +---@param path string +---@return boolean ok +function mhfu.inject_register(file_id, path) end + +---inject_register for a PAC larger than the engine's buffer: the load reads grown_path from extra +---RAM instead, and orig_path (the unedited PAC) recognises the engine's buffer. +---@param file_id integer +---@param grown_path string +---@param orig_path string +---@return boolean ok +function mhfu.inject_relocate(file_id, grown_path, orig_path) end + +---Re-reads file_id's PAC now, ignoring its mtime; returns the overwrites so far, 0 if file_id is +---unknown or the read failed. +---@param file_id integer +---@return integer +function mhfu.inject_now(file_id) end + +---The overwrites of file_id so far, 0 if it is unknown. +---@param file_id integer +---@return integer +function mhfu.inject_locate(file_id) end + +-- input (bind_input.cpp) --------------------------------------------------------------------- + +---The controller now. +---@return integer buttons the mhfu.CTRL_* bits held +---@return integer lx stick x, 0..255, centred near 128 +---@return integer ly stick y, 0..255, centred near 128 +function mhfu.buttons() end + +---The SELECT bit of `mhfu.buttons()`. +mhfu.CTRL_SELECT = 0x0001 -- PSP_CTRL_SELECT +---The START bit of `mhfu.buttons()`. +mhfu.CTRL_START = 0x0008 -- PSP_CTRL_START +---The d-pad up bit of `mhfu.buttons()`. +mhfu.CTRL_UP = 0x0010 -- PSP_CTRL_UP +---The d-pad right bit of `mhfu.buttons()`. +mhfu.CTRL_RIGHT = 0x0020 -- PSP_CTRL_RIGHT +---The d-pad down bit of `mhfu.buttons()`. +mhfu.CTRL_DOWN = 0x0040 -- PSP_CTRL_DOWN +---The d-pad left bit of `mhfu.buttons()`. +mhfu.CTRL_LEFT = 0x0080 -- PSP_CTRL_LEFT +---The L trigger bit of `mhfu.buttons()`. +mhfu.CTRL_L = 0x0100 -- PSP_CTRL_LTRIGGER +---The R trigger bit of `mhfu.buttons()`. +mhfu.CTRL_R = 0x0200 -- PSP_CTRL_RTRIGGER +---The triangle bit of `mhfu.buttons()`. +mhfu.CTRL_TRIANGLE = 0x1000 -- PSP_CTRL_TRIANGLE +---The circle bit of `mhfu.buttons()`. +mhfu.CTRL_CIRCLE = 0x2000 -- PSP_CTRL_CIRCLE +---The cross bit of `mhfu.buttons()`. +mhfu.CTRL_CROSS = 0x4000 -- PSP_CTRL_CROSS +---The square bit of `mhfu.buttons()`. +mhfu.CTRL_SQUARE = 0x8000 -- PSP_CTRL_SQUARE + +-- AI (bind_ai.cpp) --------------------------------------------------------------------------- + +---What the vt[8] picker last returned for input on this monster type (a per-run pointer for +---Tigrex), or 0 before it picked it once: a valid answer for on_bigmonster_action_decided. +---@param species integer +---@param input integer +---@return integer +function mhfu.action_ptr_for(species, input) end + +-- em_vhook, the big-monster vtable seams (bind_em.cpp) --------------------------------------- +-- A behaviour pair (main, sub) is the MOVE a big monster runs, hitbox and damage included. + +---True while a big monster's vtable is wrapped: from its spawn to the next quest. +---@return boolean +function mhfu.em_installed() end + +---Enters (main, sub) on the next AI frame through the engine's own dispatcher, so the pair is +---provisioned like a native one; false while nothing is wrapped. +---@param main integer +---@param sub integer +---@param mode? integer the enter-action's mode argument, default 0 +---@return boolean +function mhfu.em_request(main, sub, mode) end + +---Substitution slot 0..3: the engine's own choice with main in from_mask (bit k = main k) and sub +---from_sub (or mhfu.EM_ANY) is entered as (to_main, to_sub) instead, count times. +---@param slot integer +---@param from_mask integer +---@param from_sub integer +---@param to_main integer +---@param to_sub integer +---@param count integer mhfu.EM_UNLIMITED for a standing entry, 0 clears the slot +---@return true +function mhfu.em_substitute(slot, from_mask, from_sub, to_main, to_sub, count) end + +---Rule slot 0..3: a brain rule the AI step checks every frame (30 Hz), with no Lua in the loop. +---@param slot integer +---@param rule mhfu.EmRule? nil clears the slot +---@return true +function mhfu.em_rule(slot, rule) end + +---Drops every substitution, rule and pending request. +---@return true +function mhfu.em_clear() end + +---The seam's counters and its last enter-actions. +---@return mhfu.EmStatus +function mhfu.em_status() end + +---Any sub state, for `mhfu.em_substitute` and `mhfu.em_rule`. +mhfu.EM_ANY = 0xFE -- MHFU_EM_SUB_ANY +---A fire budget that never runs out (-1 in the 32-bit lua_Integer). +mhfu.EM_UNLIMITED = -1 -- MHFU_EM_UNLIMITED + +---"In a pair from `from_mask`/`from_sub` for `min_frames`, the hunter's XZ distance in +---[dist_lo, dist_hi) -> enter (to_main, to_sub, mode)"; every field is optional. +---@class mhfu.EmRule +---@field from_mask? integer bit k: may fire from main state k; default 0, never +---@field from_sub? integer exact sub, default mhfu.EM_ANY +---@field to_main? integer default 0 +---@field to_sub? integer default 0 +---@field mode? integer default 0 +---@field min_frames? integer frames the pair must have stood, default 0 +---@field dist_lo? number default 0 +---@field dist_hi? number default 1e9 +---@field receding? boolean only while the distance grows +---@field closing? boolean only while the distance shrinks +---@field cooldown? integer frames between two fires, default 0 +---@field count? integer fires allowed, default mhfu.EM_UNLIMITED + +---What `mhfu.em_status()` returns. +---@class mhfu.EmStatus +---@field installed boolean +---@field ai_ticks integer AI frames seen +---@field act_enters integer enter-actions seen +---@field last_main integer the last pair entered +---@field last_sub integer +---@field frames integer frames the current pair has stood +---@field dist number the hunter's XZ distance at the last measurement +---@field sub_hits integer substitutions made +---@field sub_landed integer substitutions whose pair reached the state cells +---@field sub_last_main integer the last substituted enter-action, before the rewrite +---@field sub_last_sub integer +---@field sub_last_mode integer +---@field brain_fires integer rule fires +---@field req_pending integer 1 while a request waits for its AI frame +---@field req_done integer requests entered +---@field req_main integer the last request's pair +---@field req_sub integer +---@field ring mhfu.EmEnter[] the last 8 enter-actions, oldest first +---@field rule_fired integer[] fires per rule slot +---@field rule_left integer[] budget left per rule slot +---@field sub_left integer[] budget left per substitution slot + +---One enter-action in `mhfu.EmStatus.ring`. +---@class mhfu.EmEnter +---@field main integer +---@field sub integer +---@field mode integer +---@field subst integer nonzero if a substitution rewrote it + +-- screen capture (bind_capture.cpp) ---------------------------------------------------------- + +---Starts or stops streaming the framebuffer on its own thread; the options apply only when +---starting with scale given. Returns whether it runs. +---@param on boolean +---@param scale? integer 1 full size, 2 half (default), up to 4 +---@param interval_ms? integer between frames, default 66 (about 15 fps) +---@param path? string default host0:/cap/stream.bin (psplink usbhostfs) +---@return boolean +function mhfu.capture(on, scale, interval_ms, path) end + +---The capture's state. +---@return boolean active +---@return integer frames written +---@return integer kb written +---@return integer last_err the last error code, 0 for none +function mhfu.capture_status() end + +-- combat for clones and scripted movesets (combat.cpp) --------------------------------------- + +---Runs the engine's attack resolution (timers, hitboxes, damage) for ent, which the engine does +---only for its ~2 managed combatants. Only from an override callback, never from mhfu_tick. +---@param ent integer +function mhfu.resolve_attack(ent) end + +---World position of bone in ent's loaded skeleton; nothing for a bad entity or bone. +---@param ent integer +---@param bone integer 0..255, the loaded skeleton's numbering +---@return number? x +---@return number? y +---@return number? z +function mhfu.bone_pos(ent, bone) end + +---Spawns engine effect effect_id at ent's bone. 0 means not spawned, which is what happens while +---ent is outside the hunter's section, not a bad id. Only from an override callback. +---@param ent integer +---@param effect_id integer +---@param bone? integer default 0, the loaded skeleton's numbering +---@return integer handle +function mhfu.spawn_effect(ent, effect_id, bone) end + +---Experimental: runs each clones_set clone's AI tick and attack resolver alongside the native's, +---so clones fight. Its code patch lands only at a title or menu screen, so enable it at load; a +---crash has been seen. +---@param enable boolean +function mhfu.clone_combat(enable) end + +---The clone pointers clone_combat and combat_nodes drive (12 at most, zeros skipped); {} stops. +---@param clones integer[] +function mhfu.clones_set(clones) end + +---Points the monster manager at the combat-node wrapper, and again whenever a quest reload reverts +---it; call each tick while combat_nodes is on. +function mhfu.combat_swap() end + +---Arms or disarms a collision node per clone each frame, copied from an engaged native Tigrex's, +---so clones damage the hunter; needs combat_swap. +---@param enable boolean +function mhfu.combat_nodes(enable) end + +---Does nothing; combat_swap does its work. +---@deprecated +function mhfu.combat_register_all() end + +-- events (events.cpp) ------------------------------------------------------------------------ +-- Each event holds one Lua handler: a second mhfu.on_X(fn) replaces the first, and priority +-- only orders it against C subscribers (higher first; the first call's stays). The game thread +-- waits for every fn but spawn, death and damaged's, so keep them short. "Register at load": +-- the event's code patch lands at the next title or menu screen. + +---Calls fn while the quest builds its monster list, before the models load: where +---quest_replace_monster and quest_add_monster go. Register at load. +---@param fn fun(quest: integer) +---@param priority? integer +function mhfu.on_quest_targets_building(fn, priority) end + +---Calls fn when a big monster enters the entity registry (5 Hz poll). +---@param fn fun(ent: integer, species: integer, slot: integer, hp: integer) +---@param priority? integer +function mhfu.on_bigmonster_spawn(fn, priority) end + +---Calls fn once when a big monster's HP reaches 0 (5 Hz poll). +---@param fn fun(ent: integer, species: integer, slot: integer) +---@param priority? integer +function mhfu.on_bigmonster_death(fn, priority) end + +---Calls fn when a big monster's HP dropped since the last 5 Hz poll (hits in between add up), +---after the engine's own flinch; intercept that with on_bigmonster_action. +---@param fn fun(ent: integer, species: integer, amount: integer, hp: integer, slot: integer) +---@param priority? integer +function mhfu.on_bigmonster_damaged(fn, priority) end + +---Calls fn after a species AI overlay is copied in and before the JIT translates it: the window +---to patch overlay code. Register at load. +---@param fn fun() +---@param priority? integer +function mhfu.on_ai_overlay_loaded(fn, priority) end + +---fn returns the body slot the slot loop processes next (cur is the engine's); one outside +---0..ctx.count-1 is ignored. Register at load. +---@param fn fun(ctx: mhfu.SlotCtx, cur: integer): integer? +---@param priority? integer +function mhfu.on_bigmonster_slot_picked(fn, priority) end + +---fn returns the input the vt[8] picker reads instead of ctx.input. Fires for one body slot only, +---so forcing here desyncs the body: force with on_bigmonster_action. +---@param fn fun(ctx: mhfu.PickCtx): integer? +---@param priority? integer +function mhfu.on_bigmonster_action_input(fn, priority) end + +---fn returns the vt[8] picker's outcome instead of value: a u16 action id for Popo, Anteka and +---Giadrome, a per-run pointer for Tigrex (action_ptr_for), never a small literal. +---@param fn fun(ctx: mhfu.PickCtx, value: integer): integer? +---@param priority? integer +function mhfu.on_bigmonster_action_decided(fn, priority) end + +---fn returns the action id the executor runs, which the engine fans out to every body slot. It +---moves the ANIMATION only, not the move and its damage (em_request). Register at load. +---@param fn fun(ctx: mhfu.ActionCtx): integer? +---@param priority? integer +function mhfu.on_bigmonster_action(fn, priority) end + +---What on_bigmonster_action passes. Check type: other entities use the executor too. +---@class mhfu.ActionCtx +---@field entity integer +---@field type integer monster type +---@field action_id integer the action the engine asked for + +---What the vt[8] picker events pass. +---@class mhfu.PickCtx +---@field entity integer +---@field type integer monster type +---@field slot integer body slot +---@field input integer the slot's picker input + +---What on_bigmonster_slot_picked passes. +---@class mhfu.SlotCtx +---@field entity integer +---@field type integer monster type +---@field slot integer the slot the loop started from +---@field count integer body slots diff --git a/framework/lua/cli_bridge.lua b/framework/lua/tools/cli_bridge.lua similarity index 100% rename from framework/lua/cli_bridge.lua rename to framework/lua/tools/cli_bridge.lua diff --git a/framework/mods/lua_host/bind_ai.cpp b/framework/mods/lua_host/bind_ai.cpp index f795dde7..164df89b 100644 --- a/framework/mods/lua_host/bind_ai.cpp +++ b/framework/mods/lua_host/bind_ai.cpp @@ -1,17 +1,10 @@ -/* mhfu.action_ptr_for binding. */ +/* mhfu.action_ptr_for binding; declared in lua/meta/mhfu.d.lua. */ #include "mhfu/mhfu.h" #include "lua_host.h" -static int lb_action_ptr_for(lua_State *L) +int lb_action_ptr_for(lua_State *L) { lua_pushinteger(L, (lua_Integer)mhfu_ai_action_ptr_for( (uint8_t)luaL_checkinteger(L,1), (uint16_t)luaL_checkinteger(L,2))); return 1; } - -static const luaL_Reg k_api[] = { - { "action_ptr_for", lb_action_ptr_for }, - { 0, 0 }, -}; - -void mhfu_lua_bind_ai(lua_State *L) { luaL_setfuncs(L, k_api, 0); } diff --git a/framework/mods/lua_host/bind_capture.cpp b/framework/mods/lua_host/bind_capture.cpp index 175ea4c2..35d08220 100644 --- a/framework/mods/lua_host/bind_capture.cpp +++ b/framework/mods/lua_host/bind_capture.cpp @@ -1,12 +1,9 @@ -/* mhfu.capture bindings over the core's framebuffer capture. */ +/* mhfu.capture bindings over the core's framebuffer capture; declared in lua/meta/mhfu.d.lua. */ #include "mhfu/mhfu.h" #include "internal.h" #include "lua_host.h" -/* mhfu.capture(on [, scale [, interval_ms [, path]]]) -> running:bool - * Streams the framebuffer to host0:/cap/stream.bin (psplink usbhostfs) on its own - * thread; scale 1 full, 2 half (default); interval_ms 66 is about 15 fps. */ -static int lb_capture(lua_State *L) +int lb_capture(lua_State *L) { int on = lua_toboolean(L, 1); if (on && !lua_isnoneornil(L, 2)) { @@ -19,8 +16,7 @@ static int lb_capture(lua_State *L) return 1; } -/* mhfu.capture_status() -> active:bool, frames:int, kb:int, last_err:int */ -static int lb_capture_status(lua_State *L) +int lb_capture_status(lua_State *L) { int f = 0, kb = 0, err = 0; int active = mhfu_capture_status(&f, &kb, &err); @@ -30,11 +26,3 @@ static int lb_capture_status(lua_State *L) lua_pushinteger(L, err); return 4; } - -static const luaL_Reg k_api[] = { - { "capture", lb_capture }, - { "capture_status", lb_capture_status }, - { 0, 0 }, -}; - -void mhfu_lua_bind_capture(lua_State *L) { luaL_setfuncs(L, k_api, 0); } diff --git a/framework/mods/lua_host/bind_em.cpp b/framework/mods/lua_host/bind_em.cpp index fd8cf61b..13571815 100644 --- a/framework/mods/lua_host/bind_em.cpp +++ b/framework/mods/lua_host/bind_em.cpp @@ -1,32 +1,14 @@ -/* mhfu.em_* bindings over em_vhook, the big-monster vtable seams (src/core/em_vhook.cpp). - * - * mhfu.em_installed() -> bool - * mhfu.em_request(main, sub [, mode]) -> bool enter the pair on the - * next AI frame through the engine's own dispatcher, so it is provisioned - * (a charge gets its run budget, which a cell write does not give it) - * mhfu.em_substitute(slot, from_mask, from_sub, to_main, to_sub, count) - * the engine's own choice of (main, id) with main in the mask and id == - * from_sub (mhfu.EM_ANY = any) is entered as (to_main, to_sub) instead; - * count = mhfu.EM_UNLIMITED for a standing entry, 0 clears - * mhfu.em_rule(slot, { from_mask=, from_sub=, to_main=, to_sub=, mode=, - * min_frames=, dist_lo=, dist_hi=, receding=, closing=, cooldown=, - * count= }) or mhfu.em_rule(slot, nil) a native 30 Hz brain rule - * mhfu.em_clear() - * mhfu.em_status() -> { installed, ai_ticks, act_enters, last_main, last_sub, - * frames, dist, sub_hits, sub_landed, sub_last_main, sub_last_sub, - * sub_last_mode, brain_fires, req_pending, req_done, req_main, req_sub, - * ring = { {main, sub, mode, subst}, ... oldest first }, - * rule_fired = {..}, rule_left = {..}, sub_left = {..} } - */ +/* mhfu.em_* bindings over em_vhook, the big-monster vtable seams (src/core/em_vhook.cpp); + * declared in lua/meta/mhfu.d.lua. */ #include "mhfu/mhfu.h" #include "lua_host.h" -static int lb_em_installed(lua_State *L) +int lb_em_installed(lua_State *L) { lua_pushboolean(L, mhfu_em_installed()); return 1; } -static int lb_em_request(lua_State *L) +int lb_em_request(lua_State *L) { int ok = mhfu_em_request((uint8_t)luaL_checkinteger(L, 1), (uint8_t)luaL_checkinteger(L, 2), @@ -34,7 +16,7 @@ static int lb_em_request(lua_State *L) lua_pushboolean(L, ok); return 1; } -static int lb_em_substitute(lua_State *L) +int lb_em_substitute(lua_State *L) { mhfu_em_substitute((int)luaL_checkinteger(L, 1), (uint8_t)luaL_checkinteger(L, 2), @@ -66,7 +48,7 @@ static int tbl_bool(lua_State *L, int idx, const char *k) lua_pop(L, 1); return v; } -static int lb_em_rule(lua_State *L) +int lb_em_rule(lua_State *L) { int slot = (int)luaL_checkinteger(L, 1); if (lua_isnoneornil(L, 2)) { mhfu_em_rule(slot, 0); lua_pushboolean(L, 1); return 1; } @@ -88,13 +70,13 @@ static int lb_em_rule(lua_State *L) lua_pushboolean(L, 1); return 1; } -static int lb_em_clear(lua_State *L) +int lb_em_clear(lua_State *L) { mhfu_em_clear(); lua_pushboolean(L, 1); return 1; } -static int lb_em_status(lua_State *L) +int lb_em_status(lua_State *L) { mhfu_em_status_t st; mhfu_em_status(&st); @@ -141,22 +123,3 @@ static int lb_em_status(lua_State *L) #undef SF_INT return 1; } - -static const luaL_Reg k_api[] = { - { "em_installed", lb_em_installed }, - { "em_request", lb_em_request }, - { "em_substitute", lb_em_substitute }, - { "em_rule", lb_em_rule }, - { "em_clear", lb_em_clear }, - { "em_status", lb_em_status }, - { 0, 0 }, -}; - -void mhfu_lua_bind_em(lua_State *L) -{ - luaL_setfuncs(L, k_api, 0); - /* EM_UNLIMITED is -1 in this build's 32-bit lua_Integer; the cast back to - * uint32_t on the way in restores it. */ - lua_pushinteger(L, (lua_Integer)MHFU_EM_SUB_ANY); lua_setfield(L, -2, "EM_ANY"); - lua_pushinteger(L, (lua_Integer)MHFU_EM_UNLIMITED); lua_setfield(L, -2, "EM_UNLIMITED"); -} diff --git a/framework/mods/lua_host/bind_entity.cpp b/framework/mods/lua_host/bind_entity.cpp index 559a04ce..67d6e0ff 100644 --- a/framework/mods/lua_host/bind_entity.cpp +++ b/framework/mods/lua_host/bind_entity.cpp @@ -1,43 +1,43 @@ -/* mhfu.* entity, entity-clone and collision-node bindings, and the MON_* ids. */ +/* mhfu.* entity, entity-clone and collision-node bindings; declared in lua/meta/mhfu.d.lua. */ #include "mhfu/mhfu.h" #include "lua_host.h" -static int lb_entity_at (lua_State *L){ lua_pushinteger(L, (lua_Integer)mhfu_entity_at((int)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_type(lua_State *L){ lua_pushinteger(L, mhfu_entity_monster_type((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_hp (lua_State *L){ lua_pushinteger(L, mhfu_entity_hp ((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_size(lua_State *L){ lua_pushnumber (L, mhfu_entity_size((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_set_size(lua_State *L){ mhfu_entity_set_size((uint32_t)luaL_checkinteger(L,1), (float)luaL_checknumber(L,2)); return 0; } -static int lb_entity_alive(lua_State *L){ lua_pushboolean(L, mhfu_entity_is_alive((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_entity_at(lua_State *L){ lua_pushinteger(L, (lua_Integer)mhfu_entity_at((int)luaL_checkinteger(L,1))); return 1; } +int lb_entity_type(lua_State *L){ lua_pushinteger(L, mhfu_entity_monster_type((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_entity_hp(lua_State *L){ lua_pushinteger(L, mhfu_entity_hp ((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_entity_size(lua_State *L){ lua_pushnumber (L, mhfu_entity_size((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_entity_set_size(lua_State *L){ mhfu_entity_set_size((uint32_t)luaL_checkinteger(L,1), (float)luaL_checknumber(L,2)); return 0; } +int lb_entity_alive(lua_State *L){ lua_pushboolean(L, mhfu_entity_is_alive((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_pos(lua_State *L) +int lb_entity_pos(lua_State *L) { mhfu_vec3_t p = mhfu_entity_pos((uint32_t)luaL_checkinteger(L,1)); lua_pushnumber(L, p.x); lua_pushnumber(L, p.y); lua_pushnumber(L, p.z); return 3; } -static int lb_entity_set_pos(lua_State *L) +int lb_entity_set_pos(lua_State *L) { mhfu_vec3_t p = { (float)luaL_checknumber(L,2), (float)luaL_checknumber(L,3), (float)luaL_checknumber(L,4) }; mhfu_entity_set_pos((uint32_t)luaL_checkinteger(L,1), p); return 0; } -static int lb_entity_yaw(lua_State *L){ lua_pushinteger(L, mhfu_entity_yaw((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_set_yaw(lua_State *L){ mhfu_entity_set_yaw((uint32_t)luaL_checkinteger(L,1), (uint16_t)luaL_checkinteger(L,2)); return 0; } -static int lb_entity_ai_state(lua_State *L){ lua_pushinteger(L, mhfu_entity_ai_state((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_set_ai_state(lua_State *L){ mhfu_entity_set_ai_state((uint32_t)luaL_checkinteger(L,1), (uint8_t)luaL_checkinteger(L,2)); return 0; } -static int lb_entity_engaged(lua_State *L){ lua_pushboolean(L, mhfu_entity_engaged((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_set_engaged(lua_State *L){ mhfu_entity_set_engaged((uint32_t)luaL_checkinteger(L,1), lua_toboolean(L,2)); return 0; } -static int lb_entity_calm(lua_State *L){ mhfu_entity_calm((uint32_t)luaL_checkinteger(L,1)); return 0; } -static int lb_entity_section(lua_State *L){ lua_pushinteger(L, mhfu_entity_section((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_entity_set_section(lua_State *L){ mhfu_entity_set_section((uint32_t)luaL_checkinteger(L,1), (uint16_t)luaL_checkinteger(L,2)); return 0; } +int lb_entity_yaw(lua_State *L){ lua_pushinteger(L, mhfu_entity_yaw((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_entity_set_yaw(lua_State *L){ mhfu_entity_set_yaw((uint32_t)luaL_checkinteger(L,1), (uint16_t)luaL_checkinteger(L,2)); return 0; } +int lb_entity_ai_state(lua_State *L){ lua_pushinteger(L, mhfu_entity_ai_state((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_entity_set_ai_state(lua_State *L){ mhfu_entity_set_ai_state((uint32_t)luaL_checkinteger(L,1), (uint8_t)luaL_checkinteger(L,2)); return 0; } +int lb_entity_engaged(lua_State *L){ lua_pushboolean(L, mhfu_entity_engaged((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_entity_set_engaged(lua_State *L){ mhfu_entity_set_engaged((uint32_t)luaL_checkinteger(L,1), lua_toboolean(L,2)); return 0; } +int lb_entity_calm(lua_State *L){ mhfu_entity_calm((uint32_t)luaL_checkinteger(L,1)); return 0; } +int lb_entity_section(lua_State *L){ lua_pushinteger(L, mhfu_entity_section((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_entity_set_section(lua_State *L){ mhfu_entity_set_section((uint32_t)luaL_checkinteger(L,1), (uint16_t)luaL_checkinteger(L,2)); return 0; } -static int lb_entity_make_visible(lua_State *L) +int lb_entity_make_visible(lua_State *L) { mhfu_entity_make_visible((uint32_t)luaL_checkinteger(L,1), (uint16_t)luaL_checkinteger(L,2)); return 0; } -static int lb_entity_force_aggro(lua_State *L) +int lb_entity_force_aggro(lua_State *L) { mhfu_vec3_t t = { (float)luaL_checknumber(L,2), (float)luaL_checknumber(L,3), (float)luaL_checknumber(L,4) }; @@ -45,8 +45,7 @@ static int lb_entity_force_aggro(lua_State *L) return 0; } -/* mhfu.entities_of_type(type) -> { ptr, ptr, ... } */ -static int lb_entities_of_type(lua_State *L) +int lb_entities_of_type(lua_State *L) { int type = (int)luaL_checkinteger(L, 1); uint32_t buf[16]; @@ -58,89 +57,40 @@ static int lb_entities_of_type(lua_State *L) } return 1; } -/* mhfu.entity_clone(src_ptr) -> clone_ptr (0 on failure): a same-species copy, - * spliced into the update chain and the registry. */ -static int lb_entity_clone(lua_State *L) +int lb_entity_clone(lua_State *L) { lua_pushinteger(L, (lua_Integer)mhfu_entity_clone((uint32_t)luaL_checkinteger(L,1))); return 1; } -/* mhfu.node_clone(template_node, clone_ent, uid) -> node_ptr (0 on failure); the - * collision node is what lets a clone damage the player */ -static int lb_node_clone(lua_State *L) +int lb_node_clone(lua_State *L) { lua_pushinteger(L, (lua_Integer)mhfu_entity_node_clone( (uint32_t)luaL_checkinteger(L,1), (uint32_t)luaL_checkinteger(L,2), (uint16_t)luaL_checkinteger(L,3))); return 1; } -/* mhfu.node_of(ent) -> node_ptr (ENTITY.COMBAT_NODE), 0 if none */ -static int lb_node_of(lua_State *L) +int lb_node_of(lua_State *L) { lua_pushinteger(L, (lua_Integer)mhfu_entity_node((uint32_t)luaL_checkinteger(L,1))); return 1; } -/* mhfu.node_linked(node) -> bool */ -static int lb_node_linked(lua_State *L) +int lb_node_linked(lua_State *L) { lua_pushboolean(L, mhfu_entity_node_linked((uint32_t)luaL_checkinteger(L,1))); return 1; } -/* mhfu.node_relink(node) -> bool (re-linked?) */ -static int lb_node_relink(lua_State *L) +int lb_node_relink(lua_State *L) { lua_pushboolean(L, mhfu_entity_node_relink((uint32_t)luaL_checkinteger(L,1))); return 1; } -/* mhfu.node_sync(node, ent) */ -static int lb_node_sync(lua_State *L) +int lb_node_sync(lua_State *L) { mhfu_entity_node_sync((uint32_t)luaL_checkinteger(L,1), (uint32_t)luaL_checkinteger(L,2)); return 0; } -/* mhfu.node_detach(node) */ -static int lb_node_detach(lua_State *L) +int lb_node_detach(lua_State *L) { mhfu_entity_node_detach((uint32_t)luaL_checkinteger(L,1)); return 0; } - -static const luaL_Reg k_api[] = { - { "entity_at", lb_entity_at }, - { "entity_type", lb_entity_type }, - { "entity_hp", lb_entity_hp }, - { "entity_size", lb_entity_size }, - { "entity_set_size", lb_entity_set_size }, - { "entity_alive", lb_entity_alive }, - { "entity_pos", lb_entity_pos }, - { "entity_set_pos", lb_entity_set_pos }, - { "entity_yaw", lb_entity_yaw }, - { "entity_set_yaw", lb_entity_set_yaw }, - { "entity_ai_state", lb_entity_ai_state }, - { "entity_set_ai_state", lb_entity_set_ai_state }, - { "entity_engaged", lb_entity_engaged }, - { "entity_set_engaged", lb_entity_set_engaged }, - { "entity_calm", lb_entity_calm }, - { "entity_section", lb_entity_section }, - { "entity_set_section", lb_entity_set_section }, - { "entity_make_visible", lb_entity_make_visible }, - { "entity_force_aggro", lb_entity_force_aggro }, - { "entity_clone", lb_entity_clone }, - { "node_clone", lb_node_clone }, - { "node_of", lb_node_of }, - { "node_linked", lb_node_linked }, - { "node_relink", lb_node_relink }, - { "node_sync", lb_node_sync }, - { "node_detach", lb_node_detach }, - { "entities_of_type", lb_entities_of_type }, - { 0, 0 }, -}; - -void mhfu_lua_bind_entity(lua_State *L) -{ - luaL_setfuncs(L, k_api, 0); - lua_pushinteger(L, MHFU_MONSTER_ANTEKA); lua_setfield(L, -2, "MON_ANTEKA"); - lua_pushinteger(L, MHFU_MONSTER_POPO); lua_setfield(L, -2, "MON_POPO"); - lua_pushinteger(L, MHFU_MONSTER_TIGREX); lua_setfield(L, -2, "MON_TIGREX"); - lua_pushinteger(L, MHFU_MONSTER_GIADROME); lua_setfield(L, -2, "MON_GIADROME"); -} diff --git a/framework/mods/lua_host/bind_inject.cpp b/framework/mods/lua_host/bind_inject.cpp index 92285e02..a0b47e13 100644 --- a/framework/mods/lua_host/bind_inject.cpp +++ b/framework/mods/lua_host/bind_inject.cpp @@ -1,10 +1,9 @@ -/* mhfu.* bindings for a relocated species overlay and live model injection. */ +/* mhfu.* bindings for a relocated species overlay and live model injection; declared in + * lua/meta/mhfu.d.lua. */ #include "mhfu/mhfu.h" #include "lua_host.h" -/* mhfu.load_relocated_overlay(path[, run_inits]) -> region_base, new_load, delta - * (or nil, errcode): loads a second em*.ovl, relocated to a fresh address. */ -static int lb_load_relocated_overlay(lua_State *L) +int lb_load_relocated_overlay(lua_State *L) { const char *path = luaL_checkstring(L, 1); int run_inits = lua_isnoneornil(L, 2) ? 0 : lua_toboolean(L, 2); @@ -18,21 +17,15 @@ static int lb_load_relocated_overlay(lua_State *L) return 3; } -/* mhfu.inject_register(file_id, path) -> ok:bool - * Watches a big-monster PAC on the memory stick and overwrites the species' - * loaded buffer in place; the worker drives mhfu_inject_tick(). */ -static int lb_inject_register(lua_State *L) +/* the worker drives mhfu_inject_tick() */ +int lb_inject_register(lua_State *L) { uint32_t id = (uint32_t)luaL_checkinteger(L, 1); const char *path = luaL_checkstring(L, 2); lua_pushboolean(L, mhfu_inject_register(id, path) == 0); return 1; } -/* mhfu.inject_relocate(file_id, grown_path, orig_path) -> ok:bool - * A PAC bigger than the engine's raw buffer: the load transform's source - * (get_subresource a0) is redirected to the grown copy in extra RAM; orig_path - * recognises the engine's buffer. */ -static int lb_inject_relocate(lua_State *L) +int lb_inject_relocate(lua_State *L) { uint32_t id = (uint32_t)luaL_checkinteger(L, 1); const char *grown = luaL_checkstring(L, 2); @@ -40,26 +33,13 @@ static int lb_inject_relocate(lua_State *L) lua_pushboolean(L, mhfu_inject_register_relocate(id, grown, orig) == 0); return 1; } -/* mhfu.inject_now(file_id) -> overwrites so far (0 = unknown id): re-read and apply now. */ -static int lb_inject_now(lua_State *L) +int lb_inject_now(lua_State *L) { lua_pushinteger(L, (lua_Integer)mhfu_inject_now((uint32_t)luaL_checkinteger(L, 1))); return 1; } -/* mhfu.inject_locate(file_id) -> overwrites so far (0 = unknown id). */ -static int lb_inject_locate(lua_State *L) +int lb_inject_locate(lua_State *L) { lua_pushinteger(L, (lua_Integer)mhfu_inject_locate((uint32_t)luaL_checkinteger(L, 1))); return 1; } - -static const luaL_Reg k_api[] = { - { "load_relocated_overlay", lb_load_relocated_overlay }, - { "inject_register", lb_inject_register }, - { "inject_relocate", lb_inject_relocate }, - { "inject_now", lb_inject_now }, - { "inject_locate", lb_inject_locate }, - { 0, 0 }, -}; - -void mhfu_lua_bind_inject(lua_State *L) { luaL_setfuncs(L, k_api, 0); } diff --git a/framework/mods/lua_host/bind_input.cpp b/framework/mods/lua_host/bind_input.cpp index b902b9b7..46235fc9 100644 --- a/framework/mods/lua_host/bind_input.cpp +++ b/framework/mods/lua_host/bind_input.cpp @@ -1,12 +1,10 @@ -/* mhfu.buttons binding and the CTRL_* button masks. */ +/* mhfu.buttons binding, declared with its CTRL_* masks in lua/meta/mhfu.d.lua. */ #include #include "mhfu/mhfu.h" #include "lua_host.h" -/* mhfu.buttons() -> buttons:u32, lx:0..255, ly:0..255: the live controller; - * the stick centres near 128, buttons are the mhfu.CTRL_* bits. */ -static int lb_buttons(lua_State *L) +int lb_buttons(lua_State *L) { SceCtrlData pad; sceCtrlPeekBufferPositive(&pad, 1); @@ -15,25 +13,3 @@ static int lb_buttons(lua_State *L) lua_pushinteger(L, (lua_Integer)pad.Ly); return 3; } - -static const luaL_Reg k_api[] = { - { "buttons", lb_buttons }, - { 0, 0 }, -}; - -void mhfu_lua_bind_input(lua_State *L) -{ - luaL_setfuncs(L, k_api, 0); - lua_pushinteger(L, PSP_CTRL_SELECT); lua_setfield(L, -2, "CTRL_SELECT"); - lua_pushinteger(L, PSP_CTRL_START); lua_setfield(L, -2, "CTRL_START"); - lua_pushinteger(L, PSP_CTRL_UP); lua_setfield(L, -2, "CTRL_UP"); - lua_pushinteger(L, PSP_CTRL_RIGHT); lua_setfield(L, -2, "CTRL_RIGHT"); - lua_pushinteger(L, PSP_CTRL_DOWN); lua_setfield(L, -2, "CTRL_DOWN"); - lua_pushinteger(L, PSP_CTRL_LEFT); lua_setfield(L, -2, "CTRL_LEFT"); - lua_pushinteger(L, PSP_CTRL_LTRIGGER); lua_setfield(L, -2, "CTRL_L"); - lua_pushinteger(L, PSP_CTRL_RTRIGGER); lua_setfield(L, -2, "CTRL_R"); - lua_pushinteger(L, PSP_CTRL_TRIANGLE); lua_setfield(L, -2, "CTRL_TRIANGLE"); - lua_pushinteger(L, PSP_CTRL_CIRCLE); lua_setfield(L, -2, "CTRL_CIRCLE"); - lua_pushinteger(L, PSP_CTRL_CROSS); lua_setfield(L, -2, "CTRL_CROSS"); - lua_pushinteger(L, PSP_CTRL_SQUARE); lua_setfield(L, -2, "CTRL_SQUARE"); -} diff --git a/framework/mods/lua_host/bind_memory.cpp b/framework/mods/lua_host/bind_memory.cpp index 3b45686e..9c3a829c 100644 --- a/framework/mods/lua_host/bind_memory.cpp +++ b/framework/mods/lua_host/bind_memory.cpp @@ -1,51 +1,29 @@ -/* mhfu.* log, raw memory and world-state bindings. */ +/* mhfu.* log, raw memory and world-state bindings; declared in lua/meta/mhfu.d.lua. */ #include "mhfu/mhfu.h" #include "lua_host.h" -static int lb_log(lua_State *L) { mhfu_log("%s", luaL_checkstring(L, 1)); return 0; } -static int lb_read_u8 (lua_State *L) { lua_pushinteger(L, mhfu_mem_read_u8 ((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_read_u16(lua_State *L) { lua_pushinteger(L, mhfu_mem_read_u16((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_read_u32(lua_State *L) { lua_pushinteger(L, (lua_Integer)mhfu_mem_read_u32((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_mem_valid(lua_State *L){ lua_pushboolean(L, mhfu_mem_valid((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_log(lua_State *L) { mhfu_log("%s", luaL_checkstring(L, 1)); return 0; } +int lb_read_u8(lua_State *L) { lua_pushinteger(L, mhfu_mem_read_u8 ((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_read_u16(lua_State *L) { lua_pushinteger(L, mhfu_mem_read_u16((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_read_u32(lua_State *L) { lua_pushinteger(L, (lua_Integer)mhfu_mem_read_u32((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_mem_valid(lua_State *L){ lua_pushboolean(L, mhfu_mem_valid((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_write_u8 (lua_State *L){ mhfu_mem_write_u8 ((uint32_t)luaL_checkinteger(L,1), (uint8_t )luaL_checkinteger(L,2)); return 0; } -static int lb_write_u16(lua_State *L){ mhfu_mem_write_u16((uint32_t)luaL_checkinteger(L,1), (uint16_t)luaL_checkinteger(L,2)); return 0; } -static int lb_write_u32(lua_State *L){ mhfu_mem_write_u32((uint32_t)luaL_checkinteger(L,1), (uint32_t)luaL_checkinteger(L,2)); return 0; } +int lb_write_u8(lua_State *L){ mhfu_mem_write_u8 ((uint32_t)luaL_checkinteger(L,1), (uint8_t )luaL_checkinteger(L,2)); return 0; } +int lb_write_u16(lua_State *L){ mhfu_mem_write_u16((uint32_t)luaL_checkinteger(L,1), (uint16_t)luaL_checkinteger(L,2)); return 0; } +int lb_write_u32(lua_State *L){ mhfu_mem_write_u32((uint32_t)luaL_checkinteger(L,1), (uint32_t)luaL_checkinteger(L,2)); return 0; } /* f32 cells as Lua numbers */ -static int lb_read_f32 (lua_State *L){ lua_pushnumber(L, mhfu_mem_read_f32((uint32_t)luaL_checkinteger(L,1))); return 1; } -static int lb_write_f32(lua_State *L){ mhfu_mem_write_f32((uint32_t)luaL_checkinteger(L,1), (float)luaL_checknumber(L,2)); return 0; } +int lb_read_f32(lua_State *L){ lua_pushnumber(L, mhfu_mem_read_f32((uint32_t)luaL_checkinteger(L,1))); return 1; } +int lb_write_f32(lua_State *L){ mhfu_mem_write_f32((uint32_t)luaL_checkinteger(L,1), (float)luaL_checknumber(L,2)); return 0; } -static int lb_screen_state(lua_State *L){ lua_pushinteger(L, mhfu_world_screen_state()); return 1; } -static int lb_area_index (lua_State *L){ lua_pushinteger(L, mhfu_world_area_index()); return 1; } -static int lb_quest_timer(lua_State *L){ lua_pushinteger(L, (lua_Integer)mhfu_world_quest_timer()); return 1; } -static int lb_player_pos(lua_State *L) +int lb_get_screen_state(lua_State *L){ lua_pushinteger(L, mhfu_world_screen_state()); return 1; } +int lb_get_area_index(lua_State *L){ lua_pushinteger(L, mhfu_world_area_index()); return 1; } +int lb_get_quest_timer(lua_State *L){ lua_pushinteger(L, (lua_Integer)mhfu_world_quest_timer()); return 1; } +int lb_player_pos(lua_State *L) { mhfu_vec3_t p = mhfu_world_player_pos(); lua_pushnumber(L, p.x); lua_pushnumber(L, p.y); lua_pushnumber(L, p.z); return 3; } -static int lb_player_hp(lua_State *L){ lua_pushinteger(L, (lua_Integer)mhfu_world_player_hp()); return 1; } -static int lb_paint_map(lua_State *L){ (void)L; mhfu_world_paint_map(); return 0; } - -static const luaL_Reg k_api[] = { - { "log", lb_log }, - { "read_u8", lb_read_u8 }, - { "read_u16", lb_read_u16 }, - { "read_u32", lb_read_u32 }, - { "write_u8", lb_write_u8 }, - { "write_u16", lb_write_u16 }, - { "write_u32", lb_write_u32 }, - { "read_f32", lb_read_f32 }, - { "write_f32", lb_write_f32 }, - { "mem_valid", lb_mem_valid }, - { "get_screen_state", lb_screen_state }, - { "get_area_index", lb_area_index }, - { "get_quest_timer", lb_quest_timer }, - { "get_player_hp", lb_player_hp }, - { "player_pos", lb_player_pos }, - { "paint_map", lb_paint_map }, - { 0, 0 }, -}; - -void mhfu_lua_bind_memory(lua_State *L) { luaL_setfuncs(L, k_api, 0); } +int lb_get_player_hp(lua_State *L){ lua_pushinteger(L, (lua_Integer)mhfu_world_player_hp()); return 1; } +int lb_paint_map(lua_State *L){ (void)L; mhfu_world_paint_map(); return 0; } diff --git a/framework/mods/lua_host/bind_quest.cpp b/framework/mods/lua_host/bind_quest.cpp index 32ed142b..d4c766ba 100644 --- a/framework/mods/lua_host/bind_quest.cpp +++ b/framework/mods/lua_host/bind_quest.cpp @@ -1,14 +1,14 @@ -/* mhfu.quest_* bindings. */ +/* mhfu.quest_* bindings; declared in lua/meta/mhfu.d.lua. */ #include "mhfu/mhfu.h" #include "lua_host.h" -static int lb_quest_has(lua_State *L) +int lb_quest_has(lua_State *L) { lua_pushboolean(L, mhfu_quest_has((mhfu_quest_t)luaL_checkinteger(L,1), (mhfu_monster_type_t)luaL_checkinteger(L,2))); return 1; } -static int lb_quest_replace_monster(lua_State *L) +int lb_quest_replace_monster(lua_State *L) { mhfu_hook_rc_t rc = mhfu_quest_replace_monster( (mhfu_quest_t)luaL_checkinteger(L,1), @@ -17,10 +17,8 @@ static int lb_quest_replace_monster(lua_State *L) lua_pushboolean(L, rc == MHFU_HOOK_OK); return 1; } -static int lb_quest_add_monster(lua_State *L) +int lb_quest_add_monster(lua_State *L) { - /* quest_add_monster(quest, id [, x, z]) — ADD a 2nd big monster. - * MUST be called from on_quest_targets_building. x/z 0 = clone source. */ float x = (float)luaL_optnumber(L, 3, 0.0); float z = (float)luaL_optnumber(L, 4, 0.0); mhfu_hook_rc_t rc = mhfu_quest_add_monster( @@ -29,24 +27,13 @@ static int lb_quest_add_monster(lua_State *L) lua_pushboolean(L, rc == MHFU_HOOK_OK); return 1; } -static int lb_quest_monster_count(lua_State *L) +int lb_quest_monster_count(lua_State *L) { lua_pushinteger(L, mhfu_quest_monster_count((mhfu_quest_t)luaL_checkinteger(L,1))); return 1; } -static int lb_quest_first_monster(lua_State *L) +int lb_quest_first_monster(lua_State *L) { lua_pushinteger(L, mhfu_quest_first_monster((mhfu_quest_t)luaL_checkinteger(L,1))); return 1; } - -static const luaL_Reg k_api[] = { - { "quest_has", lb_quest_has }, - { "quest_monster_count", lb_quest_monster_count }, - { "quest_first_monster", lb_quest_first_monster }, - { "quest_replace_monster", lb_quest_replace_monster }, - { "quest_add_monster", lb_quest_add_monster }, - { 0, 0 }, -}; - -void mhfu_lua_bind_quest(lua_State *L) { luaL_setfuncs(L, k_api, 0); } diff --git a/framework/mods/lua_host/combat.cpp b/framework/mods/lua_host/combat.cpp index 6e29e4db..43aaa976 100644 --- a/framework/mods/lua_host/combat.cpp +++ b/framework/mods/lua_host/combat.cpp @@ -1,27 +1,20 @@ /* Combat for clones and scripted movesets: the engine's attack resolver, its - * effect spawn, the per-frame clone driver and the clone combat-node swap. */ + * effect spawn, the per-frame clone driver and the clone combat-node swap; declared in + * lua/meta/mhfu.d.lua. */ #include "mhfu/mhfu.h" #include "wrap.h" #include "lua_host.h" -/* mhfu.resolve_attack(entity): the engine's own per-monster attack resolution - * (attack timers, hitboxes, damage). The engine resolves only its ~2 managed - * combatants, so this is how a clone deals damage. A heavy engine call: only from - * an override callback (game thread blocked), never from mhfu_tick. */ typedef void (*mhfu_attack_resolver_fn)(uint32_t entity); -static int lb_resolve_attack(lua_State *L) +int lb_resolve_attack(lua_State *L) { uint32_t e = (uint32_t)luaL_checkinteger(L, 1); if (e >= MHFU_MAIN_RAM && e < MHFU_EXTRA_RAM_END) ((mhfu_attack_resolver_fn)MHFU_ATTACK_RESOLVER)(e); return 0; } -/* mhfu.spawn_effect(entity, effect_id, bone) -> handle - * mhfu.bone_pos(entity, bone) -> x, y, z - * - * One of the engine's own effects at one of the entity's bones. Species code - * emits effects as literal arguments, so a ported monster only gets its own by a - * script calling this at the right frame. This is em75's spawn_effect wrapper +/* Species code emits effects as literal arguments, so a ported monster only gets its own + * by a script calling this at the right frame. This is em75's spawn_effect wrapper * (MHFU_EM75_SPAWN_EFFECT) rebuilt on addresses resident for every species: * * joints = *(u32*)(entity + ENTITY.JOINTS) @@ -30,14 +23,11 @@ static int lb_resolve_attack(lua_State *L) * base = BASE_SPECIES[entity species] ; s8 * EFFECT_SPAWN(entity, id, base, owner, bone, &pos, 3) * - * A zero handle means not spawned, not a bad id: the spawn is dropped silently - * while the entity is outside the player's section (MHFU_IN_SECTION). Bone - * indices are the loaded skeleton's; a port's numbering differs from the host's. - * Only from inside an override callback (it allocates from the effect manager); - * whether any game-thread context is enough is open. A per-frame driver on the - * ai_step prefix is withheld until shown safe; the frame-accurate spawn a moveset - * wants (MHFU_EFFECT_SPAWN_FRAMED) is reached by wrapping an em-overlay vtable - * slot, not an AI-tick prefix. */ + * MHFU_IN_SECTION drops the spawn outside the player's section. It allocates from the + * effect manager; whether any game-thread context, not only an override callback, is + * enough is open. A per-frame driver on the ai_step prefix is withheld until shown safe; + * the frame-accurate spawn a moveset wants (MHFU_EFFECT_SPAWN_FRAMED) is reached by + * wrapping an em-overlay vtable slot, not an AI-tick prefix. */ typedef uint32_t (*mhfu_fx_owner_fn)(uint32_t entity); typedef int (*mhfu_fx_spawn_fn)(uint32_t entity, int effect_id, int base_species, uint32_t owner, int bone, const float *pos, int mode); @@ -51,7 +41,7 @@ static const float *mhfu_bone_world(uint32_t ent, int bone) return (const float *)(joints + (uint32_t)bone * MHFU_JOINT_SIZE + MHFU_JOINT_POSITION); } -static int lb_bone_pos(lua_State *L) +int lb_bone_pos(lua_State *L) { const float *p = mhfu_bone_world((uint32_t)luaL_checkinteger(L, 1), (int)luaL_checkinteger(L, 2)); @@ -72,7 +62,7 @@ static int mhfu_fx_spawn(uint32_t ent, int eid, int bone) return ((mhfu_fx_spawn_fn)MHFU_EFFECT_SPAWN)(ent, eid, base, owner, bone, pos, 3); } -static int lb_spawn_effect(lua_State *L) +int lb_spawn_effect(lua_State *L) { lua_pushinteger(L, mhfu_fx_spawn((uint32_t)luaL_checkinteger(L, 1), (int)luaL_checkinteger(L, 2), @@ -122,11 +112,10 @@ static void clone_combat_step(const mhfu_bigmonster_ai_step_ctx_t *ctx) } } } -/* mhfu.clone_combat(enable). The ai_step detour installs on the first enable, so - * a script that never enables it leaves the AI tick unpatched. Experimental: the - * re-entered AI tick can misalign the stack for the engine's VFPU transform code - * (an alignment crash was seen). */ -static int lb_clone_combat(lua_State *L) +/* The ai_step detour installs on the first enable, so a script that never enables it + * leaves the AI tick unpatched. Experimental: the re-entered AI tick can misalign the + * stack for the engine's VFPU transform code (an alignment crash was seen). */ +int lb_clone_combat(lua_State *L) { int en = lua_toboolean(L, 1); g_clone_resolve = en; @@ -136,8 +125,7 @@ static int lb_clone_combat(lua_State *L) if (rc != MHFU_HOOK_OK) mhfu_log("[clonecmb] ai_step not subscribed (rc=%d)", (int)rc); return 0; } -/* mhfu.clones_set({ptr,ptr,...}) — set the live clone pointers the driver ticks. */ -static int lb_clones_set(lua_State *L) +int lb_clones_set(lua_State *L) { int n = 0; if (lua_istable(L, 1)) { @@ -238,10 +226,9 @@ static void combat_node_post(mhfu_regs_t *regs) } } -/* mhfu.combat_swap(): builds the wrapper once and re-points the field whenever it - * holds the original (a quest reload reverts it). Call each tick while - * combat_nodes(true). */ -static int lb_combat_swap(lua_State *L) +/* Builds the wrapper once and re-points the field whenever it holds the original (a + * quest reload reverts it). */ +int lb_combat_swap(lua_State *L) { (void)L; if (!g_combat_nodes) return 0; @@ -260,25 +247,9 @@ static int lb_combat_swap(lua_State *L) return 0; } -/* mhfu.combat_nodes(enable) — arm/disarm the clone combat-node builder. */ -static int lb_combat_nodes(lua_State *L) +int lb_combat_nodes(lua_State *L) { g_combat_nodes = lua_toboolean(L, 1); return 0; } -/* mhfu.combat_register_all() — a no-op kept for scripts; the field swap drives it. */ -static int lb_combat_register_all(lua_State *L) { (void)L; return 0; } - -static const luaL_Reg k_api[] = { - { "resolve_attack", lb_resolve_attack }, - { "spawn_effect", lb_spawn_effect }, - { "bone_pos", lb_bone_pos }, - { "clone_combat", lb_clone_combat }, - { "combat_nodes", lb_combat_nodes }, - { "combat_swap", lb_combat_swap }, - { "combat_register_all", lb_combat_register_all }, - { "clones_set", lb_clones_set }, - { 0, 0 }, -}; - -void mhfu_lua_bind_combat(lua_State *L) { luaL_setfuncs(L, k_api, 0); } +int lb_combat_register_all(lua_State *L) { (void)L; return 0; } diff --git a/framework/mods/lua_host/embed.S b/framework/mods/lua_host/embed.S index 5a926f0c..df0ec5e8 100644 --- a/framework/mods/lua_host/embed.S +++ b/framework/mods/lua_host/embed.S @@ -1,9 +1,9 @@ -/* The Lua run at setup, NUL-terminated for luaL_loadstring: the prelude - * (lua/_prelude.lua) and the address table without its doc comments (build/gen/addresses.lua). */ +/* The Lua run at setup, NUL-terminated for luaL_loadstring, both without their doc comments: + * the prelude (build/gen/prelude.lua) and the address table (build/gen/addresses.lua). */ .section .rodata .globl mhfu_lua_prelude mhfu_lua_prelude: - .incbin "_prelude.lua" + .incbin "prelude.lua" .byte 0 .globl mhfu_lua_addresses diff --git a/framework/mods/lua_host/events.cpp b/framework/mods/lua_host/events.cpp index 0db8d3de..1a068fb1 100644 --- a/framework/mods/lua_host/events.cpp +++ b/framework/mods/lua_host/events.cpp @@ -1,5 +1,5 @@ -/* The event bridge: mhfu.on_(fn [, priority]) stores fn and subscribes a C trampoline - * (owner lua_host); game-thread events run their Lua on the exec thread (marshal.h). */ +/* The event bridge: each mhfu.on_ (lua/meta/mhfu.d.lua) stores fn and subscribes a C + * trampoline (owner lua_host); game-thread events run their Lua on the exec thread (marshal.h). */ #include "mhfu/mhfu.h" #include "lua_host.h" @@ -29,7 +29,7 @@ static const char *err_text(lua_State *L) return lua_type(L, -1) == LUA_TSTRING ? lua_tostring(L, -1) : "(not a string)"; } -/* Push the action ctx table { entity, type, slot, input }. */ +/* Push the picker ctx table (mhfu.PickCtx). */ static void push_action_ctx(lua_State *L, uint32_t ent, uint8_t type, uint8_t slot, uint16_t input) { @@ -56,21 +56,20 @@ static int run_request(lua_State *L) { mhfu_lua_req_t *q = (mhfu_lua_req_t *)lua_touserdata(L, 1); switch (q->kind) { - case REQ_INPUT: /* function(ctx) -> new vt8_input */ + case REQ_INPUT: lua_rawgeti(L, LUA_REGISTRYINDEX, r_input); push_action_ctx(L, q->entity, q->mtype, q->slot, (uint16_t)q->in); lua_call(L, 1, 1); if (lua_isnumber(L, -1)) q->out = (uint16_t)lua_tointeger(L, -1); break; - case REQ_DECIDED: /* function(ctx, engine_value) -> value */ + case REQ_DECIDED: lua_rawgeti(L, LUA_REGISTRYINDEX, r_decided); push_action_ctx(L, q->entity, q->mtype, q->slot, q->aux); lua_pushinteger(L, (lua_Integer)q->in); lua_call(L, 2, 1); if (lua_isnumber(L, -1)) q->out = (uint32_t)lua_tointeger(L, -1); break; - case REQ_ACTIONSEL: /* function({ entity, type, action_id }) -> new action id, which - * the executor fans out to every body slot itself */ + case REQ_ACTIONSEL: lua_rawgeti(L, LUA_REGISTRYINDEX, r_action); lua_createtable(L, 0, 3); lua_pushinteger(L, (lua_Integer)q->entity); lua_setfield(L, -2, "entity"); @@ -79,7 +78,7 @@ static int run_request(lua_State *L) lua_call(L, 1, 1); if (lua_isnumber(L, -1)) q->out = (uint32_t)lua_tointeger(L, -1); break; - case REQ_SLOT: /* function({ entity, type, slot, count }, cur) -> slot */ + case REQ_SLOT: lua_rawgeti(L, LUA_REGISTRYINDEX, r_slot); lua_createtable(L, 0, 4); lua_pushinteger(L, (lua_Integer)q->entity); lua_setfield(L, -2, "entity"); @@ -97,7 +96,7 @@ static int run_request(lua_State *L) lua_rawgeti(L, LUA_REGISTRYINDEX, r_overlay); lua_call(L, 0, 0); break; - case REQ_QUEST: /* function(quest_ptr) */ + case REQ_QUEST: lua_rawgeti(L, LUA_REGISTRYINDEX, r_quest); lua_pushinteger(L, (lua_Integer)q->entity); lua_call(L, 1, 0); @@ -204,7 +203,7 @@ static void tramp_death(const mhfu_bigmonster_death_ctx_t *ctx) mhfu_lua_leave(); } -/* poll thread; Lua: function(entity_ptr, monster_type, amount, hp, slot) */ +/* poll thread */ static void tramp_damaged(const mhfu_bigmonster_damaged_ctx_t *ctx) { if (r_damaged == LUA_NOREF || !mhfu_lua_enter()) return; @@ -229,7 +228,7 @@ static void tramp_quest(const mhfu_quest_ctx_t *ctx) marshal(REQ_QUEST, (uint32_t)ctx->quest, 0, 0, 0, 0); } -/* ---- mhfu.on_(fn [, priority]): a second call rebinds fn; the first priority stays ---- */ +/* ---- the registrars ---- */ static int prio(lua_State *L) { return (int)luaL_optinteger(L, 2, 0); } @@ -238,79 +237,64 @@ static void check(const char *api, mhfu_hook_rc_t rc) if (rc != MHFU_HOOK_OK) mhfu_log("[lua_host] mhfu.%s: not subscribed (rc=%d)", api, (int)rc); } -static int lb_on_quest(lua_State *L) +int lb_on_quest_targets_building(lua_State *L) { store_ref(L, &r_quest); check("on_quest_targets_building", mhfu_on_quest_targets_building(tramp_quest, prio(L), MHFU_LUA_HOST_ID)); return 0; } -static int lb_on_spawn(lua_State *L) +int lb_on_bigmonster_spawn(lua_State *L) { store_ref(L, &r_spawn); check("on_bigmonster_spawn", mhfu_on_bigmonster_spawn(tramp_spawn, prio(L), MHFU_LUA_HOST_ID)); return 0; } -static int lb_on_death(lua_State *L) +int lb_on_bigmonster_death(lua_State *L) { store_ref(L, &r_death); check("on_bigmonster_death", mhfu_on_bigmonster_death(tramp_death, prio(L), MHFU_LUA_HOST_ID)); return 0; } -static int lb_on_damaged(lua_State *L) +int lb_on_bigmonster_damaged(lua_State *L) { store_ref(L, &r_damaged); check("on_bigmonster_damaged", mhfu_on_bigmonster_damaged(tramp_damaged, prio(L), MHFU_LUA_HOST_ID)); return 0; } -static int lb_on_overlay(lua_State *L) +int lb_on_ai_overlay_loaded(lua_State *L) { store_ref(L, &r_overlay); check("on_ai_overlay_loaded", mhfu_on_ai_overlay_loaded(tramp_overlay, prio(L), MHFU_LUA_HOST_ID)); return 0; } -static int lb_on_slot(lua_State *L) +int lb_on_bigmonster_slot_picked(lua_State *L) { store_ref(L, &r_slot); check("on_bigmonster_slot_picked", mhfu_on_bigmonster_slot_picked(tramp_slot, prio(L), MHFU_LUA_HOST_ID)); return 0; } -static int lb_on_input(lua_State *L) +int lb_on_bigmonster_action_input(lua_State *L) { store_ref(L, &r_input); check("on_bigmonster_action_input", mhfu_on_bigmonster_action_input(tramp_input, prio(L), MHFU_LUA_HOST_ID)); return 0; } -static int lb_on_decided(lua_State *L) +int lb_on_bigmonster_action_decided(lua_State *L) { store_ref(L, &r_decided); check("on_bigmonster_action_decided", mhfu_on_bigmonster_action_decided(tramp_decided, prio(L), MHFU_LUA_HOST_ID)); return 0; } -static int lb_on_action(lua_State *L) +int lb_on_bigmonster_action(lua_State *L) { store_ref(L, &r_action); check("on_bigmonster_action", mhfu_on_bigmonster_action(tramp_action, prio(L), MHFU_LUA_HOST_ID)); return 0; } - -static const luaL_Reg k_api[] = { - { "on_quest_targets_building", lb_on_quest }, - { "on_bigmonster_spawn", lb_on_spawn }, - { "on_bigmonster_death", lb_on_death }, - { "on_bigmonster_damaged", lb_on_damaged }, - { "on_ai_overlay_loaded", lb_on_overlay }, - { "on_bigmonster_slot_picked", lb_on_slot }, - { "on_bigmonster_action_input", lb_on_input }, - { "on_bigmonster_action_decided", lb_on_decided }, - { "on_bigmonster_action", lb_on_action }, - { 0, 0 }, -}; - -void mhfu_lua_bind_events(lua_State *L) { luaL_setfuncs(L, k_api, 0); } diff --git a/framework/mods/lua_host/freecam.cpp b/framework/mods/lua_host/freecam.cpp index 0af7af72..e3028f4f 100644 --- a/framework/mods/lua_host/freecam.cpp +++ b/framework/mods/lua_host/freecam.cpp @@ -1,8 +1,8 @@ /* * Freecam: flies the camera by overriding the engine's orbit-camera inputs, so * the engine still builds eye, matrix and GE upload itself. Toggle: double-tap - * SELECT. Not installed: that toggle collides with monster_nameplates' (see - * freecam_install). + * SELECT. Not installed, and without Lua bindings until it is: that toggle collides + * with monster_nameplates' (see freecam_install). * * The quest camera update (MHFU_CAM_ORBIT_UPDATE) builds eye = pivot + * R(euler angles) * offset: @@ -19,7 +19,6 @@ #include "mhfu/mhfu.h" #include "internal.h" -#include "lua_host.h" /* displaced instructions the stubs replay */ #define ENC_SWC1_F1_S4_44 0xE6810044u @@ -28,9 +27,6 @@ #define ENC_LWC1_F0_SP_1C 0xC7A0001Cu static volatile int g_fc_active = 0; -static volatile int g_fc_snap_off = 0; /* set from Lua, read by nothing yet */ -static volatile int g_fc_village = 0; /* set from Lua, read by nothing yet */ -static volatile int g_fc_freeze = 1; /* set from Lua, read by nothing yet */ static volatile int g_fc_inited = 0; static volatile int g_fc_ang_cap = 0; /* quest orbit-angle seed captured */ static float g_fc_pivot[3] = {0,0,0}; /* camera focus x/z (eye anchor) */ @@ -49,7 +45,7 @@ static uint32_t *g_cam_w4 = 0; /* injection 4, quest and village: the toggle and the pad; flies the village eye * (nothing else writes it) and sets the look-at to eye + aim. */ -extern "C" void mhfu_lua_cam_target_c(void) +static void mhfu_lua_cam_target_c(void) { SceCtrlData pad; sceCtrlPeekBufferPositive(&pad, 1); unsigned b = pad.Buttons; @@ -110,7 +106,7 @@ extern "C" void mhfu_lua_cam_target_c(void) } /* injection 3: eye_y = our height + the rotated offset's y. */ -extern "C" void mhfu_lua_cam_eye_y_c(uint32_t s4base, uint32_t player) +static void mhfu_lua_cam_eye_y_c(uint32_t s4base, uint32_t player) { if (!g_fc_active) return; float *eye_y = (float *)(s4base + MHFU_CAM_ORBIT_EYE + 4); @@ -120,7 +116,7 @@ extern "C" void mhfu_lua_cam_eye_y_c(uint32_t s4base, uint32_t player) /* injection 1, quest only: our position into the orbit pivot, and the orbit * angles frozen so the d-pad (aim, injection 4) does not also orbit the eye. */ -extern "C" void mhfu_lua_cam_orbit_c(uint32_t cam_sp, uint32_t player) +static void mhfu_lua_cam_orbit_c(uint32_t cam_sp, uint32_t player) { (void)player; if (!g_fc_active) return; @@ -246,34 +242,3 @@ static int freecam_install(void) return (r0==MHFU_HOOK_OK&&r1==MHFU_HOOK_OK&&r4==MHFU_HOOK_OK&&r5==MHFU_HOOK_OK &&r6==MHFU_HOOK_OK&&r7==MHFU_HOOK_OK) ? 0 : -1; } - -static int lb_freecam(lua_State *L){ g_fc_active = lua_toboolean(L,1); g_fc_inited = 0; return 0; } -static int lb_freecam_active(lua_State *L){ lua_pushboolean(L, g_fc_active); return 1; } -static int lb_cam_snap_disable(lua_State *L){ g_fc_snap_off = lua_toboolean(L,1); return 0; } -static int lb_cam_freeze_player(lua_State *L){ g_fc_freeze = lua_toboolean(L,1); return 0; } -static int lb_cam_village_unlock(lua_State *L){ g_fc_village = lua_toboolean(L,1); return 0; } -static int lb_cam_config(lua_State *L){ - if (!lua_isnoneornil(L,1)) g_fc_move_spd = (float)luaL_checknumber(L,1); - if (!lua_isnoneornil(L,2)) g_fc_rot_spd = (float)luaL_checknumber(L,2); - if (!lua_isnoneornil(L,3)) g_fc_look_dist = (float)luaL_checknumber(L,3); - return 0; -} -static int lb_cam_eye(lua_State *L){ - lua_pushnumber(L, *(volatile float*)MHFU_CAM_EYE); - lua_pushnumber(L, *(volatile float*)(MHFU_CAM_EYE+4)); - lua_pushnumber(L, *(volatile float*)(MHFU_CAM_EYE+8)); - return 3; -} - -static const luaL_Reg k_api[] = { - { "freecam", lb_freecam }, - { "freecam_active", lb_freecam_active }, - { "cam_snap_disable", lb_cam_snap_disable }, - { "cam_freeze_player", lb_cam_freeze_player }, - { "cam_village_unlock", lb_cam_village_unlock }, - { "cam_config", lb_cam_config }, - { "cam_eye", lb_cam_eye }, - { 0, 0 }, -}; - -void mhfu_lua_bind_freecam(lua_State *L) { luaL_setfuncs(L, k_api, 0); } diff --git a/framework/mods/lua_host/init.cpp b/framework/mods/lua_host/init.cpp index 2cc61383..c2958184 100644 --- a/framework/mods/lua_host/init.cpp +++ b/framework/mods/lua_host/init.cpp @@ -1,12 +1,14 @@ /* lua_host: a sandboxed Lua 5.4 VM running mods from the memory stick against the * mhfu.* API. Setup, the 10 Hz worker and the mod descriptor. */ +#include #include #include #include "mhfu/mhfu.h" #include "lua_host.h" +#include "lua_api.gen.inc" /* k_mhfu_funcs, k_mhfu_consts */ -/* embed.S: NUL-terminated copies of lua/_prelude.lua and the generated address table */ +/* embed.S: NUL-terminated copies of lua/lib/_prelude.lua and the generated address table */ extern "C" const char mhfu_lua_prelude[]; extern "C" const char mhfu_lua_addresses[]; @@ -25,17 +27,11 @@ static int mhfu_index(lua_State *L) static void register_mhfu_api(lua_State *L) { lua_newtable(L); /* the mhfu table */ - mhfu_lua_bind_memory(L); - mhfu_lua_bind_entity(L); - mhfu_lua_bind_quest(L); - mhfu_lua_bind_inject(L); - mhfu_lua_bind_input(L); - mhfu_lua_bind_ai(L); - mhfu_lua_bind_em(L); - mhfu_lua_bind_capture(L); - mhfu_lua_bind_freecam(L); - mhfu_lua_bind_combat(L); - mhfu_lua_bind_events(L); + luaL_setfuncs(L, k_mhfu_funcs, 0); + for (int i = 0; k_mhfu_consts[i].name; i++) { + lua_pushinteger(L, k_mhfu_consts[i].value); + lua_setfield(L, -2, k_mhfu_consts[i].name); + } lua_newtable(L); /* its metatable: mhfu.addr on first use */ lua_pushcfunction(L, mhfu_index); lua_setfield(L, -2, "__index"); @@ -46,7 +42,7 @@ static void register_mhfu_api(lua_State *L) static void remove_unsafe_globals(lua_State *L) { static const char *unsafe[] = { - "dofile", "loadfile", "load", "loadstring", "require", + "dofile", "loadfile", "load", "loadstring", "collectgarbage", "rawget", "rawset", "rawequal", "rawlen", 0 }; for (int i = 0; unsafe[i]; i++) { @@ -55,7 +51,8 @@ static void remove_unsafe_globals(lua_State *L) } } -/* Only base, table, string and math are opened; io, os and debug are not linked. */ +/* Only base, table, string, math and a require limited to mods/lib are opened; io, os and + * debug are not linked. */ static int lua_host_setup(void) { if (mhfu_lua_vm_open() != 0) return -1; @@ -65,6 +62,7 @@ static int lua_host_setup(void) luaL_requiref(L, "table", luaopen_table, 1); lua_pop(L, 1); luaL_requiref(L, "string", luaopen_string, 1); lua_pop(L, 1); luaL_requiref(L, "math", luaopen_math, 1); lua_pop(L, 1); + mhfu_lua_install_require(L); remove_unsafe_globals(L); register_mhfu_api(L); @@ -86,9 +84,11 @@ static int lua_host_setup(void) mhfu_lua_have_tick = lua_isfunction(L, -1); lua_pop(L, 1); - mhfu_log("[lua_host] VM ready: lua_Number=%dB live=%uB peak=%uB tick=%d", - (int)sizeof(lua_Number), mhfu_lua_slab_live(), mhfu_lua_slab_peak, - mhfu_lua_have_tick); + mhfu_log("[lua_host] VM ready: api=%d lua_Number=%dB live=%uB peak=%uB tick=%d", + MHFU_LUA_API_VERSION, (int)sizeof(lua_Number), mhfu_lua_slab_live(), + mhfu_lua_slab_peak, mhfu_lua_have_tick); + /* the scripts were just read from ms0, so a boot error need not wait for gameplay */ + mhfu_log_flush_held(); return 0; } diff --git a/framework/mods/lua_host/lua_host.h b/framework/mods/lua_host/lua_host.h index d40875e3..42c85685 100644 --- a/framework/mods/lua_host/lua_host.h +++ b/framework/mods/lua_host/lua_host.h @@ -38,18 +38,9 @@ void mhfu_lua_scripts_init(void); /* the read buffer */ int mhfu_lua_load_dir(lua_State *L); /* runs every script; returns how many ran */ void mhfu_lua_prime_tracked(void); /* records each script's stat for hot reload */ void mhfu_lua_hot_reload_scan(void); +void mhfu_lua_install_require(lua_State *L); /* package, with only the mods/lib searcher */ -/* --- the mhfu table: each adds its functions and constants to the table on top --- */ -void mhfu_lua_bind_memory(lua_State *L); -void mhfu_lua_bind_entity(lua_State *L); -void mhfu_lua_bind_quest(lua_State *L); -void mhfu_lua_bind_inject(lua_State *L); -void mhfu_lua_bind_input(lua_State *L); -void mhfu_lua_bind_ai(lua_State *L); -void mhfu_lua_bind_em(lua_State *L); -void mhfu_lua_bind_capture(lua_State *L); -void mhfu_lua_bind_freecam(lua_State *L); -void mhfu_lua_bind_combat(lua_State *L); -void mhfu_lua_bind_events(lua_State *L); +/* --- the mhfu table: lb_ for each function lua/meta/mhfu.d.lua declares --- */ +#include "lua_api.gen.h" #endif /* MHFU_LUA_HOST_H */ diff --git a/framework/mods/lua_host/scripts.cpp b/framework/mods/lua_host/scripts.cpp index 7d5cb107..d5e0cbd6 100644 --- a/framework/mods/lua_host/scripts.cpp +++ b/framework/mods/lua_host/scripts.cpp @@ -1,6 +1,9 @@ -/* Mods are .lua files in LUA_MODS_DIR: run at boot, re-run when one changes. */ +/* Mods are .lua files in LUA_MODS_DIR: run at boot, re-run when one changes. Libraries are + * .lua files in its lib/ subdirectory, run only through require and re-run in place when one + * that has been required changes. */ #include #include +#include #include #include @@ -8,6 +11,7 @@ #include "lua_host.h" #define LUA_MODS_DIR "ms0:/PSP/PLUGINS/mhfu_framework/mods" +#define LIB_PREFIX "lib/" /* Per-file read buffer, allocated at init to keep it off the PRX load image. * A script over this size is skipped with only one boot-log line; keep every * script (and the port runtime library) under 96 KB. */ @@ -15,8 +19,8 @@ static char *g_filebuf; static SceUID g_filebuf_uid = -1; -/* Hot reload tracks MAX_TRACKED scripts with names under NAME_CAP bytes; any other script - * never runs, since an untracked one would re-run on every scan. */ +/* Hot reload tracks MAX_TRACKED scripts and libraries with names (lib/ included) under NAME_CAP + * bytes; any other script never runs, since an untracked one would re-run on every scan. */ #define MAX_TRACKED 16 #define NAME_CAP 64 @@ -48,9 +52,8 @@ static int ends_with_lua(const char *s) && (s[n-1]|0x20) == 'a'; } -/* Read, compile and run one script by basename. The caller holds the VM (setup - * is single-threaded; hot reload takes the lock). 0 ok / -1 err. */ -static int load_lua_file(lua_State *L, const char *name) +/* Read one script (a basename, or lib/.lua) into g_filebuf; its length, or -1. */ +static int read_script(const char *name) { script_path_t path; script_path(path, name); @@ -65,7 +68,14 @@ static int load_lua_file(lua_State *L, const char *name) return -1; } g_filebuf[n] = 0; + return n; +} +/* Read and compile one script, leaving its chunk on the stack; -1 on failure (logged). */ +static int compile_script(lua_State *L, const char *name) +{ + int n = read_script(name); + if (n < 0) return -1; char chunk[NAME_CAP + 1]; snprintf(chunk, sizeof(chunk), "@%.*s", NAME_CAP - 1, name); /* '@': a file name */ if (luaL_loadbuffer(L, g_filebuf, (size_t)n, chunk) != LUA_OK) { @@ -73,6 +83,15 @@ static int load_lua_file(lua_State *L, const char *name) lua_pop(L, 1); return -1; } + return n; +} + +/* Read, compile and run one mod by basename. The caller holds the VM (setup + * is single-threaded; hot reload takes the lock). 0 ok / -1 err. */ +static int load_lua_file(lua_State *L, const char *name) +{ + int n = compile_script(L, name); + if (n < 0) return -1; if (lua_pcall(L, 0, 0, 0) != LUA_OK) { mhfu_log("[lua_host] run FAILED %s: %s", name, lua_tostring(L, -1)); lua_pop(L, 1); @@ -87,7 +106,8 @@ static int load_lua_file(lua_State *L, const char *name) * mhfu.on_*(fn) calls replace the stored handlers, so the installed hooks call * the new closures with no reinstall. One shared global env: a script's module * locals reset on reload, two scripts on one event means the last wins, and a - * deleted script's handlers stay registered. */ + * deleted script's handlers stay registered. A library re-runs with + * package.loaded[name] still set, so one that keeps state can reuse its table. */ typedef struct { char name[NAME_CAP]; SceOff size; @@ -96,6 +116,7 @@ typedef struct { static tracked_t g_tracked[MAX_TRACKED]; static int g_ntracked; static int g_refusal_logged; +static int g_boot_load; /* the boot load reads ms0 before gameplay */ static int find_tracked(const char *name) { @@ -121,6 +142,104 @@ static void log_refusal(const char *name) g_refusal_logged = 1; } +/* Records the file's (size, mtime): -1 no file, 1 it differs from the recorded one, else 0. */ +static int restat(tracked_t *t) +{ + script_path_t path; + script_path(path, t->name); + SceIoStat st; + memset(&st, 0, sizeof(st)); + if (sceIoGetstat(path, &st) < 0) return -1; + int changed = t->size != st.st_size + || memcmp(&t->mtime, &st.sce_st_mtime, sizeof(ScePspDateTime)) != 0; + t->size = st.st_size; + t->mtime = st.sce_st_mtime; + return changed; +} + +/* ------------------------------------------------------------ require + * package.searchers is {preload, lib_searcher}: require("name") runs mods/lib/name.lua once + * and caches what it returns in package.loaded. Outside the boot load a library is read only + * in gameplay, so a mod requires its libraries at its top level. */ +static int lib_searcher(lua_State *L) +{ + const char *mod = luaL_checkstring(L, 1); + for (const char *c = mod; *c; c++) + if (!isalnum((unsigned char)*c) && *c != '_') { + lua_pushfstring(L, "no library '%s': names are letters, digits and _", mod); + return 1; + } + char name[NAME_CAP]; + if (!*mod || snprintf(name, sizeof(name), LIB_PREFIX "%s.lua", mod) >= (int)sizeof(name)) { + lua_pushfstring(L, "no library '%s': the name is empty or too long", mod); + return 1; + } + if (!g_boot_load && !mhfu_world_ms0_io_safe()) { + lua_pushfstring(L, "mods/%s not read: no Memory Stick access now, require it at a " + "mod's top level", name); + return 1; + } + script_path_t path; + script_path(path, name); + SceIoStat st; + memset(&st, 0, sizeof(st)); + if (sceIoGetstat(path, &st) < 0) { + lua_pushfstring(L, "no file 'mods/%s'", name); + return 1; + } + int idx = track_slot(name); + if (idx < 0) { + log_refusal(name); + return luaL_error(L, "mods/%s not loaded: hot reload tracks at most %d files", + name, MAX_TRACKED); + } + restat(&g_tracked[idx]); + if (compile_script(L, name) < 0) + return luaL_error(L, "mods/%s did not load (framework.log has why)", name); + lua_pushstring(L, path); + return 2; +} + +void mhfu_lua_install_require(lua_State *L) +{ + luaL_requiref(L, LUA_LOADLIBNAME, luaopen_package, 1); /* package and require */ + lua_getfield(L, -1, "searchers"); + lua_pushcfunction(L, lib_searcher); lua_rawseti(L, -2, 2); + lua_pushnil(L); lua_rawseti(L, -2, 4); + lua_pushnil(L); lua_rawseti(L, -2, 3); + lua_pop(L, 1); + /* the rest of package reads files or loads C */ + static const char *const drop[] = { "loadlib", "searchpath", "path", "cpath", 0 }; + for (int i = 0; drop[i]; i++) { lua_pushnil(L); lua_setfield(L, -2, drop[i]); } + lua_pop(L, 1); +} + +/* Re-runs a required library in place; 0 ok, 1 not required yet, -1 failed (logged). */ +static int rerun_library(lua_State *L, const char *name) +{ + char mod[NAME_CAP]; + snprintf(mod, sizeof(mod), "%s", name + sizeof(LIB_PREFIX) - 1); + mod[strlen(mod) - 4] = 0; /* ".lua" */ + luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE); + int required = lua_getfield(L, -1, mod) != LUA_TNIL; + lua_pop(L, 1); + if (!required) { lua_pop(L, 1); return 1; } + if (compile_script(L, name) < 0) { lua_pop(L, 1); return -1; } + script_path_t path; + script_path(path, name); + lua_pushstring(L, mod); + lua_pushstring(L, path); + if (lua_pcall(L, 2, 1, 0) != LUA_OK) { + mhfu_log("[lua_host] run FAILED %s: %s", name, lua_tostring(L, -1)); + lua_pop(L, 2); + return -1; + } + if (lua_isnil(L, -1)) lua_pop(L, 1); + else lua_setfield(L, -2, mod); + lua_pop(L, 1); + return 0; +} + int mhfu_lua_load_dir(lua_State *L) { SceUID d = sceIoDopen(LUA_MODS_DIR); @@ -128,17 +247,19 @@ int mhfu_lua_load_dir(lua_State *L) mhfu_log("[lua_host] mods dir absent (%s) rc=0x%08X", LUA_MODS_DIR, (unsigned)d); return 0; } + g_boot_load = 1; int loaded = 0; SceIoDirent ent; for (;;) { memset(&ent, 0, sizeof(ent)); int r = sceIoDread(d, &ent); if (r <= 0) break; /* end of dir / error */ - if (FIO_S_ISDIR(ent.d_stat.st_mode)) continue; /* skip subdirs */ + if (FIO_S_ISDIR(ent.d_stat.st_mode)) continue; /* skip subdirs, lib/ among them */ if (!ends_with_lua(ent.d_name)) continue; if (track_slot(ent.d_name) < 0) { log_refusal(ent.d_name); continue; } if (load_lua_file(L, ent.d_name) == 0) loaded++; } + g_boot_load = 0; sceIoDclose(d); return loaded; } @@ -146,15 +267,16 @@ int mhfu_lua_load_dir(lua_State *L) /* After the boot load, so the first poll does not see every file as changed. */ void mhfu_lua_prime_tracked(void) { - for (int i = 0; i < g_ntracked; i++) { - script_path_t path; - script_path(path, g_tracked[i].name); - SceIoStat st; - memset(&st, 0, sizeof(st)); - if (sceIoGetstat(path, &st) < 0) continue; - g_tracked[i].size = st.st_size; - g_tracked[i].mtime = st.sce_st_mtime; - } + for (int i = 0; i < g_ntracked; i++) restat(&g_tracked[i]); +} + +/* The VM is held: the tick may have changed hands, and the old closures are garbage. */ +static void after_reload(lua_State *L) +{ + lua_getglobal(L, "mhfu_tick"); + mhfu_lua_have_tick = lua_isfunction(L, -1); + lua_pop(L, 1); + lua_gc(L, LUA_GCCOLLECT, 0); } /* Worker thread. The stat is recorded before the reload, so a script that fails @@ -179,31 +301,32 @@ void mhfu_lua_hot_reload_scan(void) if (!g_refusal_logged) log_refusal(ent.d_name); continue; } - script_path_t path; - script_path(path, ent.d_name); - SceIoStat st; - memset(&st, 0, sizeof(st)); - if (sceIoGetstat(path, &st) < 0) continue; - tracked_t *t = &g_tracked[idx]; - if (!fresh && t->size == st.st_size - && memcmp(&t->mtime, &st.sce_st_mtime, sizeof(ScePspDateTime)) == 0) - continue; - t->size = st.st_size; /* recorded first (no retry spin) */ - t->mtime = st.sce_st_mtime; + int changed = restat(&g_tracked[idx]); /* recorded first (no retry spin) */ + if (changed < 0 || (!fresh && !changed)) continue; if (!mhfu_lua_enter()) break; /* VM busy/dead — try later */ mhfu_log("[lua_host] hot-reload %s ...", ent.d_name); if (load_lua_file(mhfu_lua_vm, ent.d_name) == 0) { - lua_getglobal(mhfu_lua_vm, "mhfu_tick"); - mhfu_lua_have_tick = lua_isfunction(mhfu_lua_vm, -1); - lua_pop(mhfu_lua_vm, 1); - lua_gc(mhfu_lua_vm, LUA_GCCOLLECT, 0); /* reclaim old closures */ + after_reload(mhfu_lua_vm); reloaded++; } mhfu_lua_leave(); } sceIoDclose(d); + + for (int i = 0; i < g_ntracked; i++) { + tracked_t *t = &g_tracked[i]; + if (strncmp(t->name, LIB_PREFIX, sizeof(LIB_PREFIX) - 1) != 0) continue; + if (restat(t) != 1) continue; + if (!mhfu_lua_enter()) break; + mhfu_log("[lua_host] hot-reload %s ...", t->name); + if (rerun_library(mhfu_lua_vm, t->name) == 0) { + after_reload(mhfu_lua_vm); + reloaded++; + } + mhfu_lua_leave(); + } if (reloaded) - mhfu_log("[lua_host] hot-reloaded %d mod(s), live=%uB", + mhfu_log("[lua_host] hot-reloaded %d script(s), live=%uB", reloaded, mhfu_lua_slab_live()); } diff --git a/framework/src/core/log.cpp b/framework/src/core/log.cpp index 0576d872..031d14b2 100644 --- a/framework/src/core/log.cpp +++ b/framework/src/core/log.cpp @@ -9,8 +9,8 @@ static SceUID g_log_fd = -1; -/* Lines logged before ms0 is safe are held here and flushed on the first safe write, so a boot - * failure (a mod that fails to compile) still reaches the log; overflow is counted. */ +/* Lines logged while ms0 is not safe are held here and flushed on the next safe write or + * mhfu_log_flush_held; overflow is counted. */ #define EARLY_CAP 4096 static char g_early[EARLY_CAP]; static unsigned g_early_n; @@ -41,6 +41,16 @@ static void flush_early(void) } } +static int open_log(void) +{ + if (g_log_fd < 0) { + g_log_fd = sceIoOpen( + "ms0:/PSP/PLUGINS/mhfu_framework/framework.log", + PSP_O_WRONLY | PSP_O_CREAT | PSP_O_APPEND, 0666); + } + return g_log_fd >= 0; +} + extern "C" void mhfu_log(const char *fmt, ...) { char buf[256]; @@ -55,18 +65,18 @@ extern "C" void mhfu_log(const char *fmt, ...) * Memory Stick driver, and writing during a load or save freezes the PSP. */ if (!mhfu_world_ms0_io_safe()) { stash(buf, n); return; } - if (g_log_fd < 0) { - g_log_fd = sceIoOpen( - "ms0:/PSP/PLUGINS/mhfu_framework/framework.log", - PSP_O_WRONLY | PSP_O_CREAT | PSP_O_APPEND, 0666); - } - if (g_log_fd >= 0) { + if (open_log()) { flush_early(); sceIoWrite(g_log_fd, buf, (size_t)n); sceIoWrite(g_log_fd, "\n", 1); } } +extern "C" void mhfu_log_flush_held(void) +{ + if (open_log()) flush_early(); +} + extern "C" void mhfu_log_close(void) { if (g_log_fd >= 0) { sceIoClose(g_log_fd); g_log_fd = -1; } diff --git a/framework/tests/conftest.py b/framework/tests/conftest.py index a8958071..86e7dc90 100644 --- a/framework/tests/conftest.py +++ b/framework/tests/conftest.py @@ -1,12 +1,14 @@ """Host builds of framework sources, for tests that call them through ctypes.""" import ctypes +import importlib.util import shutil import struct import subprocess import sys from collections.abc import Callable, Sequence from pathlib import Path +from typing import Any import pytest from unicorn import ( @@ -186,3 +188,38 @@ def ctc1(rt: Reg, fs: int) -> int: def mips() -> type[Mips]: """The Mips class: Mips() is a fresh machine, Mips.jal(...) and friends encode words.""" return Mips + + +@pytest.fixture +def lua() -> Any: + """A Lua 5.4 runtime set up like lua_host: `require` finds lua/lib, and the global `mhfu` + holds every function lua/meta/mhfu.d.lua declares as a stub returning 0, every constant, + `addr`, and the lists `calls` ({name, args...}) and `logs`. Tests replace fields.""" + from lupa.lua54 import LuaRuntime + from mhfu import addresses + + spec = importlib.util.spec_from_file_location("lua_api", FRAMEWORK / "tools" / "lua_api.py") + assert spec and spec.loader + lua_api = importlib.util.module_from_spec(spec) + spec.loader.exec_module(lua_api) + api = lua_api.parse((FRAMEWORK / "lua" / "meta" / "mhfu.d.lua").read_text(encoding="utf-8")) + + rt = LuaRuntime() + rt.execute(f"package.path = {str(FRAMEWORK / 'lua' / 'lib' / '?.lua')!r}") + build = rt.eval( + """function(funcs, consts, addr) + local m = { calls = {}, logs = {}, addr = addr } + for _, name in ipairs(funcs) do + m[name] = function(...) m.calls[#m.calls + 1] = { name, ... }; return 0 end + end + for name, value in pairs(consts) do m[name] = value end + m.log = function(s) m.logs[#m.logs + 1] = s end + mhfu = m + end""" + ) + build( + rt.table_from(list(api.funcs)), + rt.table_from({k.name: k.value for k in api.consts}), + rt.execute(addresses.render_lua(addresses.table())), + ) + return rt diff --git a/framework/tests/test_cli_bridge.py b/framework/tests/test_cli_bridge.py index b6f91ecb..0eb047ba 100644 --- a/framework/tests/test_cli_bridge.py +++ b/framework/tests/test_cli_bridge.py @@ -6,7 +6,7 @@ from lupa.lua54 import LuaRuntime from mhfu import addresses as a -SCRIPT = Path(__file__).parents[1] / "lua" / "cli_bridge.lua" +SCRIPT = Path(__file__).parents[1] / "lua" / "tools" / "cli_bridge.lua" MAGIC = 0x4D484252 FORCE, FREEZE, CLEAR = 1, 2, 3 FREEZE_BITS = 0x10100 diff --git a/framework/tests/test_examples.py b/framework/tests/test_examples.py new file mode 100644 index 00000000..1e32f9b9 --- /dev/null +++ b/framework/tests/test_examples.py @@ -0,0 +1,137 @@ +"""The example mods in lupa, against the declared API.""" + +from pathlib import Path +from typing import Any + +import pytest +from mhfu import addresses as a + +LUA = Path(__file__).parents[1] / "lua" +TIGREX, GIADROME, POPO = 0x4B, 0x4D, 0x46 +ENT = a.RAM.start + 0x100000 +QUEST = a.RAM.start + 0x200000 +SPIN = 43 + + +def run(lua: Any, name: str) -> Any: + """Runs examples/.lua; returns the fake `mhfu`.""" + lua.execute((LUA / "examples" / f"{name}.lua").read_text(encoding="utf-8")) + return lua.globals().mhfu + + +def handler(m: Any, event: str) -> Any: + return next(c[2] for c in m.calls.values() if c[1] == event) + + +def calls(m: Any, name: str) -> list[tuple[Any, ...]]: + return [tuple(c.values())[1:] for c in m.calls.values() if c[1] == name] + + +def action(lua: Any, species: int = TIGREX) -> Any: + return lua.table_from({"entity": ENT, "type": species, "action_id": 5}) + + +def test_action_override_pulses(lua: Any) -> None: + m = run(lua, "action_override") + on_action = handler(m, "on_bigmonster_action") + ctx = action(lua) + clock = [90000] + m.get_quest_timer = lambda: clock[0] + assert on_action(ctx) == SPIN + clock[0] -= 299 + assert on_action(ctx) is None + clock[0] -= 1 + assert on_action(ctx) == SPIN + assert "[action_override] a1 5 -> 43 (spin)" in m.logs.values() + + +def test_action_override_leaves_the_rest(lua: Any) -> None: + m = run(lua, "action_override") + on_action = handler(m, "on_bigmonster_action") + assert on_action(action(lua, GIADROME)) is None + m.entity_section = lambda _ent: 1 # not the player's section + assert on_action(action(lua)) is None + + +def test_action_override_clears_the_freeze_gate(lua: Any) -> None: + m = run(lua, "action_override") + m.read_u32 = lambda _at: 0x10107 + handler(m, "on_bigmonster_action")(action(lua)) + assert calls(m, "write_u32") == [(ENT + a.ENTITY.FREEZE_GATE, 0x7)] + + +@pytest.mark.parametrize( + ("monsters", "done"), + [({TIGREX}, ["add"]), ({GIADROME}, ["replace", "add"]), ({POPO}, [])], +) +def test_second_monster_adds_a_tigrex(lua: Any, monsters: set[int], done: list[str]) -> None: + m = run(lua, "second_monster") + quest, did = set(monsters), [] + + def replace(_q: int, old: int, new: int) -> bool: + quest.discard(old) + quest.add(new) + did.append("replace") + return True + + def add(_q: int, mon: int) -> bool: + did.append("add") + return mon == TIGREX + + m.quest_has = lambda _q, mon: mon in quest + m.quest_replace_monster, m.quest_add_monster = replace, add + handler(m, "on_quest_targets_building")(QUEST) + assert did == done + assert ("[second_monster] added a second Tigrex" in m.logs.values()) == bool(done) + + +def test_second_monster_logs_tigrex_spawns(lua: Any) -> None: + m = run(lua, "second_monster") + spawn = handler(m, "on_bigmonster_spawn") + spawn(ENT, POPO, 1, 100) + spawn(ENT, TIGREX, 2, 3200) + assert list(m.logs.values())[-1] == "[second_monster] Tigrex in slot 2, hp 3200" + + +FAKE_PORT = """ +local port = {} +function port.mod(name, fn) + port.name = name + fn({ + log = function(fmt, ...) mhfu.logs[#mhfu.logs + 1] = string.format(fmt, ...) end, + define = function(spec) + port.spec = spec + return { brain = function(self, f) port.brain = f; return self end } + end, + }) +end +package.loaded.mhfu_port = port +""" + + +def test_ported_brute_reports_section_changes(lua: Any) -> None: + lua.execute(FAKE_PORT) + m = run(lua, "ported_brute") + port = lua.eval("package.loaded.mhfu_port") + assert (port.name, port.spec.name, port.spec.fid) == ("ported_brute", "brute_tigrex", 6186) + for state in ( + {"same_section": False, "dist": 9000}, + {"same_section": True, "dist": 2000.4}, + {"same_section": True, "dist": 1500}, + {"same_section": False, "dist": 4000}, + ): + port.brain(lua.table_from(state)) + assert list(m.logs.values()) == [ + "[ported_brute] in your section, 2000 units away", + "[ported_brute] left your section", + ] + + +@pytest.mark.skipif( + not (LUA / "lib" / "mhfu_port.lua").exists(), reason="lib/mhfu_port.lua not written yet" +) +def test_ported_brute_loads_on_mhfu_port(lua: Any) -> None: + m = run(lua, "ported_brute") + fid, pac, orig = calls(m, "inject_relocate")[0] + assert fid == 6186 + assert pac.endswith("/brute_tigrex.bin") and orig.endswith("/file_06185.bin.orig") diff --git a/framework/tests/test_lua_api.py b/framework/tests/test_lua_api.py new file mode 100644 index 00000000..b6794601 --- /dev/null +++ b/framework/tests/test_lua_api.py @@ -0,0 +1,66 @@ +import importlib.util +from pathlib import Path + +import pytest + +FRAMEWORK = Path(__file__).parents[1] +_spec = importlib.util.spec_from_file_location("lua_api", FRAMEWORK / "tools" / "lua_api.py") +assert _spec and _spec.loader +tool = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(tool) + +DECL = """---@meta +---@class mhfu +mhfu = {} + +---The version. +mhfu.api_version = 3 + +---Reads a byte. +---@param addr integer +---@return integer +function mhfu.read_u8(addr) end + +---The up bit. +mhfu.CTRL_UP = 0x0010 -- PSP_CTRL_UP +""" + + +def test_parses_functions_and_constants() -> None: + api = tool.parse(DECL) + assert api.version == 3 + assert api.funcs == ("read_u8",) + assert [(k.name, k.value, k.c_name) for k in api.consts] == [ + ("api_version", 3, None), + ("CTRL_UP", 0x10, "PSP_CTRL_UP"), + ] + + +def test_renders_prototypes_and_tables() -> None: + api = tool.parse(DECL) + assert "int lb_read_u8(lua_State *L);" in tool.render_header(api) + assert "#define MHFU_LUA_API_VERSION 3" in tool.render_header(api) + tables = tool.render_tables(api) + assert '{ "read_u8", lb_read_u8 },' in tables + assert '{ "api_version", 3 },' in tables + assert '{ "CTRL_UP", (lua_Integer)(PSP_CTRL_UP) },' in tables + assert "static_assert((int32_t)(PSP_CTRL_UP) == (int32_t)(0x0010)" in tables + + +@pytest.mark.parametrize( + ("decl", "error"), + [ + (DECL.replace("---Reads a byte.\n", ""), "no doc line"), + (DECL + "\n---Again.\nfunction mhfu.read_u8(addr) end\n", "declared twice"), + (DECL.replace("mhfu.api_version = 3", "mhfu.version = 3"), "api_version"), + ], +) +def test_rejects(decl: str, error: str) -> None: + with pytest.raises(ValueError, match=error): + tool.parse(decl) + + +def test_the_declaration_parses() -> None: + api = tool.parse((FRAMEWORK / "lua" / "meta" / "mhfu.d.lua").read_text(encoding="utf-8")) + assert api.version >= 1 + assert "on_bigmonster_action" in api.funcs diff --git a/framework/tests/test_mhfu_port.py b/framework/tests/test_mhfu_port.py new file mode 100644 index 00000000..f423511f --- /dev/null +++ b/framework/tests/test_mhfu_port.py @@ -0,0 +1,87 @@ +"""mhfu_port as a library, against the declared API.""" + +from pathlib import Path +from typing import Any + +import pytest +from lupa.lua54 import LuaError + +LIB = Path(__file__).parents[1] / "lua" / "lib" / "mhfu_port.lua" + +# re-runs the library in place, as lua_host does when a required library changes +RELOAD = f""" +local f = assert(loadfile({str(LIB)!r})) +package.loaded.mhfu_port = f("mhfu_port") or package.loaded.mhfu_port +""" + + +def test_require(lua: Any) -> None: + assert lua.eval('require("mhfu_port") == package.loaded.mhfu_port') + assert lua.eval('type(mhfu_tick) == "function" and mhfu.port == nil') + + +def test_mod_replaces(lua: Any) -> None: + lua.execute( + """ + local port = require("mhfu_port") + runs = { a = 0, b = 0 } + port.mod("m", function(P) runs.a = runs.a + 1; P.define{ name = "x", species = 75 } end) + first = runs.a + port.mod("m", function(P) runs.b = runs.b + 1; P.define{ name = "x", species = 75 } end) + """ + + RELOAD + ) + assert lua.eval("first") == 1 + assert (lua.eval("runs.a"), lua.eval("runs.b")) == (1, 2) + + +def test_hot_reload(lua: Any) -> None: + lua.execute( + """ + port = require("mhfu_port") + port.mod("m", function(P) P.define{ name = "x", species = 75 } end) + before = port.ports.x + """ + + RELOAD + ) + assert lua.eval('require("mhfu_port") == port') + assert lua.eval("port.ports.x ~= nil and port.ports.x ~= before") + + +def test_log_survives_reload(lua: Any) -> None: + lua.execute('port = require("mhfu_port"); old_log = port.log' + RELOAD) + lua.execute('old_log("from a handler of the first load"); mhfu_tick()') + assert "from a handler of the first load" in lua.eval("mhfu.logs").values() + + +def test_play_fallback(lua: Any) -> None: + lua.execute( + """ + port = require("mhfu_port") + mhfu.em_installed = function() return false end + mhfu.entities_of_type = function() return { 0x100000 } end + port.mod("m", function(P) + P.define{ name = "x", species = 75, clips = { run = 61 }, + moves = { charge = { main = 2, sub = 8, clip = "run" } } } + :brain(function(s) if not s.move then s.port:play("charge") end end) + end) + mhfu.calls = {} + mhfu_tick() + function wrote(off, v) + for _, c in ipairs(mhfu.calls) do + if c[1] == "write_u8" and c[2] == 0x100000 + off and c[3] == v then return true end + end + return false + end + """ + ) + assert lua.eval( + "wrote(mhfu.addr.ENTITY.MAIN_STATE, 2) and wrote(mhfu.addr.ENTITY.SUB_STATE, 8)" + ) + assert lua.eval("port.ports.x.clip") == 61 + + +def test_api_version(lua: Any) -> None: + lua.execute("mhfu.api_version = 0") + with pytest.raises(LuaError, match=r"api_version >= 1"): + lua.execute('require("mhfu_port")') diff --git a/framework/tests/test_prelude.py b/framework/tests/test_prelude.py new file mode 100644 index 00000000..5c3bc2ec --- /dev/null +++ b/framework/tests/test_prelude.py @@ -0,0 +1,12 @@ +"""The prelude against the declared API, in lupa.""" + +from pathlib import Path +from typing import Any + +PRELUDE = Path(__file__).parents[1] / "lua" / "lib" / "_prelude.lua" + + +def test_runs_against_the_declared_api(lua: Any) -> None: + lua.execute(PRELUDE.read_text(encoding="utf-8")) + assert lua.eval("mhfu.world.player() ~= nil and mhfu.MON.TIGREX == mhfu.MON_TIGREX") + assert any("[prelude]" in s for s in lua.eval("mhfu.logs").values()) diff --git a/framework/tools/lua_api.py b/framework/tools/lua_api.py new file mode 100644 index 00000000..f0958c5b --- /dev/null +++ b/framework/tools/lua_api.py @@ -0,0 +1,121 @@ +"""Render the C side of the Lua API from its declaration, lua/meta/mhfu.d.lua. + +The declaration is the LuaLS definition file mod authors read, so completion and the registered +table cannot disagree. A function is `function mhfu.name(...) end` and a constant is +`mhfu.NAME = `, optionally followed by `-- C_NAME`: the value is then taken from C and +checked against the declared one at compile time. Each needs a doc line above it. + + python tools/lua_api.py lua/meta/mhfu.d.lua -o build/gen + +writes lua_api.gen.h (the lb_ prototypes and MHFU_LUA_API_VERSION) and lua_api.gen.inc +(the function and constant tables, for the one file that registers them). +""" + +import argparse +import re +from dataclasses import dataclass +from pathlib import Path + +FUNC = re.compile(r"^function mhfu\.([a-z_][a-z0-9_]*)\(.*\) end$") +CONST = re.compile(r"^mhfu\.(\w+) = (-?(?:0x[0-9A-Fa-f]+|\d+))(?: -- ([A-Z_][A-Z0-9_]*))?$") +VERSION = "api_version" + + +@dataclass(frozen=True) +class Const: + name: str + value: int + literal: str + c_name: str | None + + +@dataclass(frozen=True) +class Api: + version: int + funcs: tuple[str, ...] + consts: tuple[Const, ...] + + +def parse(text: str) -> Api: + """The functions and constants declared on `mhfu`, in file order.""" + funcs: list[str] = [] + consts: list[Const] = [] + seen: set[str] = set() + documented = False + for n, raw in enumerate(text.splitlines(), 1): + line = raw.rstrip() + m = FUNC.match(line) or CONST.match(line) + if m: + name = m[1] + if not documented: + raise ValueError(f"line {n}: mhfu.{name} has no doc line") + if name in seen: + raise ValueError(f"line {n}: mhfu.{name} declared twice") + seen.add(name) + if m.re is FUNC: + funcs.append(name) + else: + consts.append(Const(name, int(m[2], 0), m[2], m[3])) + if not line.startswith("---"): + documented = False + elif not line.startswith("---@"): + documented = True + version = [k.value for k in consts if k.name == VERSION] + if not version: + raise ValueError(f"mhfu.{VERSION} is not declared") + return Api(version[0], tuple(funcs), tuple(consts)) + + +def render_header(api: Api) -> str: + lines = [ + "/* The Lua API's C side, generated from lua/meta/mhfu.d.lua by tools/lua_api.py. */", + "#ifndef MHFU_LUA_API_GEN_H", + "#define MHFU_LUA_API_GEN_H", + "", + f"#define MHFU_LUA_API_VERSION {api.version}", + "", + *(f"int lb_{name}(lua_State *L);" for name in api.funcs), + "", + "#endif", + ] + return "\n".join(lines) + "\n" + + +def render_tables(api: Api) -> str: + lines = [ + "/* mhfu's functions and constants, generated from lua/meta/mhfu.d.lua by", + " * tools/lua_api.py. Included by the one file that registers them. */", + "static const luaL_Reg k_mhfu_funcs[] = {", + *(f' {{ "{name}", lb_{name} }},' for name in api.funcs), + " { 0, 0 },", + "};", + "", + "static const struct { const char *name; lua_Integer value; } k_mhfu_consts[] = {", + ] + for k in api.consts: + value = f"(lua_Integer)({k.c_name})" if k.c_name else k.literal + lines.append(f' {{ "{k.name}", {value} }},') + lines += [" { 0, 0 },", "};", ""] + for k in api.consts: + if k.c_name: + lines.append( + f"static_assert((int32_t)({k.c_name}) == (int32_t)({k.literal}), " + f'"mhfu.d.lua declares {k.name} = {k.literal}");' + ) + return "\n".join(lines) + "\n" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("decl", type=Path) + ap.add_argument("-o", "--out", type=Path, required=True, help="output directory") + args = ap.parse_args(argv) + api = parse(args.decl.read_text(encoding="utf-8")) + args.out.mkdir(parents=True, exist_ok=True) + (args.out / "lua_api.gen.h").write_text(render_header(api), encoding="utf-8") + (args.out / "lua_api.gen.inc").write_text(render_tables(api), encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())