Skip to content

Commit 7259463

Browse files
sp00ktoberclaude
andcommitted
fix: the bridge applies a command once; play in game sends only what changed and waits for it
A reload of cli_bridge.lua forgot the last SEQ and applied it again: it now applies a SEQ not yet in ACK. Each studio play rewrote both port modules, so the game re-ran them (the clips module then failed to compile for memory) and the relink restarted the move: modules() writes a module only when it changed. The moves module carries its build, and play_own(build=) asks until the port runs that build (RESULT 2 while stale), so a new or edited move is not asked of the old module. The bridge's ack wait is 10 s: arming a breakpoint stalls it >3 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 62c4fb6 commit 7259463

18 files changed

Lines changed: 194 additions & 68 deletions

File tree

‎apps/studio/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@ a handle for the end, which drags the clip's `turn`, or a fixed steer's angle, i
6969
Play in game saves, writes the port's clips and moves modules to the memory stick's `mods/lib` as
7070
`mhfu-port inject` does, and asks the running port to play the move by name; it refuses a move
7171
whose clip sits in another anim than in the saved manifest, since the game holds the build
72-
injected from the file. A port defined before takes a new or renamed move once its mod reloads.
72+
injected from the file. It writes only the modules that changed, and asks for the move from the
73+
moves module just built, so a new or edited move plays once the game has re-run that module.
7374
A move asked while the monster has noticed the hunter but is not yet in combat waits for combat,
7475
so the "!" and the howl play out; Force beside the button plays it at once.
7576

‎apps/studio/src/mhfu_studio/monster/move_game.py‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import TYPE_CHECKING
1212

1313
from mhfu import inject
14-
from mhfu_port import build, layout
14+
from mhfu_port import build, layout, moves
1515
from mhfu_port.data import Data
1616
from mhfu_port.manifest import Manifest
1717

@@ -21,22 +21,25 @@
2121
from mhfu.live.session import Session
2222

2323

24-
def deploy(m: Manifest, games: Data, mods: Path) -> list[Path]:
25-
"""`m` built as it is, its modules written into `mods/lib`, and `mhfu_port.lua` kept in
26-
step; what was written. The game re-runs a changed module."""
24+
def deploy(m: Manifest, games: Data, mods: Path) -> tuple[list[Path], int]:
25+
"""`m` built as it is, its modules written into `mods/lib` where they changed, and
26+
`mhfu_port.lua` kept in step: what was written, and the build of the moves module now on the
27+
stick, the one the port must run. The game re-runs a changed module."""
2728
from mhfu_port.cli.build import modules # the writer `mhfu-port inject` uses
2829

2930
out = modules(m, build.build(m, games), mods / layout.LIB, games)
3031
lib = runtime.sync_library(mods, runtime.library())
31-
return out if lib is None else [*out, lib]
32+
text = (mods / layout.LIB / moves.module_name(m)).read_text(encoding="utf-8")
33+
return (out if lib is None else [*out, lib]), moves.build_of(text)
3234

3335

34-
def play_own(s: Session, name: str, force: bool = False) -> bool:
36+
def play_own(s: Session, name: str, force: bool = False, build: int = 0) -> bool:
3537
"""`mhfu.live.moves.play_own`: False when no port rides the big monster or it has no own
36-
move `name`; `force` plays it at once even before combat. The seam the tests fake."""
37-
from mhfu.live import moves
38+
move `name`; `force` plays it at once even before combat; `build` waits for that moves
39+
module. The seam the tests fake."""
40+
from mhfu.live import moves as live
3841

39-
return moves.play_own(s, name, force=force)
42+
return live.play_own(s, name, force=force, build=build)
4043

4144

4245
class Running:

‎apps/studio/src/mhfu_studio/monster/workspace.py‎

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def __init__(self, data: Data | None = None, intel_root: Path | None = None) ->
177177
#: the running game, for Play in game
178178
self.game_session: Callable[[], AbstractContextManager[Session]] = clip_game.attached
179179
#: asks the running port for an own move (Play in game in Moves)
180-
self.play_own: Callable[[Session, str, bool], bool] = move_game.play_own
180+
self.play_own: Callable[[Session, str, bool, int], bool] = move_game.play_own
181181
self.game_running: Callable[[], bool] = move_game.Running()
182182
self._reset()
183183

@@ -1888,9 +1888,9 @@ def play_move_in_game(self, force: bool = False) -> None:
18881888
if clip is not None:
18891889
self.entry_in_game(doc.manifest.clips[clip].id)
18901890
doc.save()
1891-
sent = move_game.deploy(doc.manifest, self.games(), self.mods_dir())
1891+
sent, build = move_game.deploy(doc.manifest, self.games(), self.mods_dir())
18921892
with self.game_session() as s:
1893-
took = self.play_own(s, name, force)
1893+
took = self.play_own(s, name, force, build)
18941894
except (LookupError, OSError, ValueError, DebuggerError) as e:
18951895
self.message = f"not played in the game: {e}"
18961896
return
@@ -1900,10 +1900,12 @@ def play_move_in_game(self, force: bool = False) -> None:
19001900
" own move by that name"
19011901
)
19021902
return
1903-
where = _home(sent[0].parent) if sent else "the memory stick"
1904-
self.message = (
1905-
f"{name} plays in the game; saved, {', '.join(p.name for p in sent)} sent to {where}"
1903+
news = (
1904+
f"{', '.join(p.name for p in sent)} sent to {_home(sent[0].parent)}"
1905+
if sent
1906+
else "the memory stick's modules were current"
19061907
)
1908+
self.message = f"{name} plays in the game; saved, {news}"
19071909

19081910

19091911
def _roots() -> tuple[Path | None, ...]:

‎apps/studio/tests/monster/test_move_game.py‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ def zinogre(
4141
ws.game_session = lambda: nullcontext("session") # type: ignore[assignment,arg-type,return-value]
4242
ws.game_running = lambda: True
4343

44-
def play_own(s: Any, name: str, force: bool) -> bool:
45-
assert s == "session"
44+
def play_own(s: Any, name: str, force: bool, build: int) -> bool:
45+
assert s == "session" and build > 0
4646
asked.append((name, force))
4747
return True
4848

@@ -80,18 +80,19 @@ def test_saves_deploys_and_asks(
8080
assert moves.module_name(m) in ws.message
8181
ws.play_move_in_game(force=True)
8282
assert asked[-1] == (name, True)
83+
assert ws.message.endswith("saved, the memory stick's modules were current")
8384

8485

8586
def test_not_taken(zinogre: MonsterWorkspace) -> None:
8687
name, _ = own_and_pair(zinogre)
8788
zinogre.select_move(name)
88-
zinogre.play_own = lambda s, n, f: False
89+
zinogre.play_own = lambda s, n, f, b: False
8990
zinogre.play_move_in_game()
9091
assert zinogre.message.startswith(f"the game did not take {name}: no port rides")
9192

9293

9394
def test_bridge_missing(zinogre: MonsterWorkspace) -> None:
94-
def play_own(s: Any, name: str, force: bool) -> bool:
95+
def play_own(s: Any, name: str, force: bool, build: int) -> bool:
9596
raise TimeoutError("no ack from cli_bridge.lua")
9697

9798
zinogre.select_move(own_and_pair(zinogre)[0])
@@ -136,14 +137,27 @@ def test_moved_clip_refused(zinogre: MonsterWorkspace, asked: list[tuple[str, bo
136137
def test_the_seam(monkeypatch: pytest.MonkeyPatch) -> None:
137138
seen: list[tuple[object, ...]] = []
138139

139-
def play_own(s: object, name: str, **kw: bool) -> bool:
140+
def play_own(s: object, name: str, **kw: object) -> bool:
140141
seen.append((s, name, kw))
141142
return True
142143

143144
monkeypatch.setattr(live_moves, "play_own", play_own)
144145
assert move_game.play_own("s", "stamp") is True # type: ignore[arg-type]
145-
assert move_game.play_own("s", "stamp", force=True) is True # type: ignore[arg-type]
146-
assert seen == [("s", "stamp", {"force": False}), ("s", "stamp", {"force": True})]
146+
assert move_game.play_own("s", "stamp", force=True, build=7) is True # type: ignore[arg-type]
147+
assert seen == [
148+
("s", "stamp", {"force": False, "build": 0}),
149+
("s", "stamp", {"force": True, "build": 7}),
150+
]
151+
152+
153+
def test_a_stale_module(zinogre: MonsterWorkspace) -> None:
154+
def play_own(s: Any, name: str, force: bool, build: int) -> bool:
155+
raise live_moves.Stale(f"the port does not run moves module build {build:08X}")
156+
157+
zinogre.select_move(own_and_pair(zinogre)[0])
158+
zinogre.play_own = play_own
159+
zinogre.play_move_in_game()
160+
assert zinogre.message.startswith("not played in the game: the port does not run moves")
147161

148162

149163
def test_running_scans_at_most_every_ttl(monkeypatch: pytest.MonkeyPatch) -> None:

‎framework/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ hold, enters its own move's carrier instead of `(4,x)`, and the move plays from
136136
frame. `force = true` plays a rule's move past the notice wait. `ports/README.md` "Moves and
137137
rules" has the manifest's side.
138138

139+
## Calling the game from C
140+
141+
C calls a game function through `mhfu_call(fn, a0, ...)` (`mhfu/call.h`, word arguments only).
142+
The game's VFPU code stores quads into its stack frame, which needs 16-byte alignment, and
143+
psp-gcc keeps frames at 8: a direct call halts the game at the first such store.
144+
`tests/test_engine_calls.py` refuses one.
145+
139146
## Tests
140147

141148
`uv run pytest framework` compiles framework sources for the host and runs the Lua against the

‎framework/lua/lib/mhfu_port.lua‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,8 +369,11 @@ end
369369
--- it starts at the monster's next AI frame, its clip on every body part, its attacks and steer
370370
--- as declared, and its `after` follows in C when it ends on its clip, its length or a wall.
371371
--- Asked while the monster's notice runs (mhfu.monster_state), it waits for combat unless
372-
--- `opts.force`. A scripted pair move ends here. False while the seam is not live.
372+
--- `opts.force`. A scripted pair move ends here. False while the seam is not live; false,
373+
--- "stale" when `opts.build` (not 0) is not the build of the moves module the port runs.
373374
function Port:move(name, opts)
375+
local want = opts ~= nil and opts.build or 0
376+
if want ~= 0 and not (self._made and self._made.build == want) then return false, "stale" end
374377
local mv = self.moves[name]
375378
if not is_own(mv) then
376379
log("[port:%s] move('%s'): not an own move", self.name, tostring(name))

‎framework/lua/tools/cli_bridge.lua‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ local CMD_CLEAR = 3
2727
local CMD_MOVE = 4 -- the MOVE struct in the block, on SLOT's monster
2828
local ARG_OWN = 1 -- ...or, with this ARG bit, the port's own move NAME
2929
local ARG_FORCE = 2 -- ...played past the monster's notice
30+
local STALE = 2 -- RESULT: the port's moves module is not build ARG2 yet
3031

3132
-- loaded before the tick is wrapped below, so the library's mhfu_tick is the one wrapped
3233
local has_port, port_lib = pcall(require, "mhfu_port")
3334
local PORTS = has_port and type(port_lib) == "table" and port_lib or nil
3435

35-
local last_seq = -1
3636
local g_force = nil -- forced action id, or nil
3737
local g_ent = nil -- the entity it is forced on
3838
local g_port = nil -- the port that latches it, or nil for the own hook
@@ -117,11 +117,11 @@ end
117117
-- global (not local) so the chained wrapper resolves the FRESH definition after a
118118
-- hot-reload instead of a stale captured upvalue
119119
function cli_bridge_tick()
120-
-- poll the command block (only act on a new seq)
120+
-- poll the command block: a SEQ not yet in ACK is new, and a reload of this script finds the
121+
-- ACK in the block, so it never applies a command twice
121122
if mhfu.read_u32(BR + CB.MAGIC) == MAGIC then
122123
local seq = mhfu.read_u32(BR + CB.SEQ)
123-
if seq ~= last_seq then
124-
last_seq = seq
124+
if seq ~= mhfu.read_u32(BR + CB.ACK) then
125125
local cmd = mhfu.read_u32(BR + CB.CMD)
126126
local a0 = mhfu.read_u32(BR + CB.SLOT)
127127
local a1 = mhfu.read_u32(BR + CB.ARG) -- action id / on-off
@@ -140,9 +140,12 @@ function cli_bridge_tick()
140140
elseif cmd == CMD_MOVE and (a1 & ARG_OWN) ~= 0 then
141141
local ent = entity(a0)
142142
local p = ent and port_of(ent)
143-
local ok = p and p:move(name_at(BR + CB.NAME, CB.NAME_COUNT),
144-
{ force = (a1 & ARG_FORCE) ~= 0 })
145-
mhfu.write_u32(BR + CB.RESULT, ok and 1 or 0)
143+
local ok, why = false, nil
144+
if p then
145+
ok, why = p:move(name_at(BR + CB.NAME, CB.NAME_COUNT),
146+
{ force = (a1 & ARG_FORCE) ~= 0, build = mhfu.read_u32(BR + CB.ARG2) })
147+
end
148+
mhfu.write_u32(BR + CB.RESULT, ok and 1 or why == "stale" and STALE or 0)
146149
elseif cmd == CMD_MOVE then
147150
local ent = entity(a0)
148151
-- a refused move leaves the block's PENDING and STARTED as they were

‎framework/tests/test_cli_bridge.py‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,11 @@ def _move_stop(self) -> bool:
6060
def gate(self, slot: int) -> int:
6161
return self.mem.get(self.slots[slot] + GATE, 0)
6262

63-
def send(self, cmd: int, slot: int = 1, arg: int = 0) -> None:
63+
def send(self, cmd: int, slot: int = 1, arg: int = 0, arg2: int = 0) -> None:
6464
"""Write one command and run a tick; the script acks it."""
6565
self.seq += 1
6666
words = {CB.MAGIC: MAGIC, CB.SEQ: self.seq, CB.CMD: cmd, CB.SLOT: slot, CB.ARG: arg}
67+
words[CB.ARG2] = arg2
6768
self.mem.update({BR + at: value for at, value in words.items()})
6869
self.tick()
6970
assert self.mem[BR + CB.ACK] == self.seq
@@ -98,6 +99,7 @@ def test_clear_releases_the_action(game):
9899
release = function(self) self.log[#self.log + 1] = "release" end,
99100
move = function(self, name, opts)
100101
self.log[#self.log + 1] = "move " .. name .. (opts.force and " forced" or "")
102+
if opts.build ~= 0 and opts.build ~= 7 then return false, "stale" end
101103
return name == "stamp"
102104
end } } }"""
103105

@@ -156,13 +158,21 @@ def test_move_plays_the_block_spec_on_the_slot(game):
156158
def test_own_move_by_name_through_the_port():
157159
game = Game(PORT_LIB % Game().slots[1])
158160

159-
def own(name: str, slot: int = 1, arg: int = 1) -> int:
161+
def own(name: str, slot: int = 1, arg: int = 1, build: int = 0) -> int:
160162
raw = name.encode().ljust(CB.NAME.count or 0, b"\0")
161163
game.mem.update({BR + CB.NAME + i: c for i, c in enumerate(raw)})
162-
game.send(MOVE, slot=slot, arg=arg)
164+
game.send(MOVE, slot=slot, arg=arg, arg2=build)
163165
return game.mem[BR + CB.RESULT]
164166

165167
assert (own("stamp"), own("nope"), own("stamp", slot=2), own("stamp", arg=3)) == (1, 0, 0, 1)
168+
assert (own("stamp", build=7), own("stamp", build=8)) == (1, 2)
166169
log = game.lua.eval("package.loaded.mhfu_port.ports.zin.log")
167-
assert list(log.values()) == ["move stamp", "move nope", "move stamp forced"]
170+
assert list(log.values())[:3] == ["move stamp", "move nope", "move stamp forced"]
168171
assert game.moves == []
172+
173+
174+
def test_a_reload_applies_nothing_twice(game):
175+
game.send(MOVE, slot=2)
176+
game.lua.execute(SCRIPT.read_text())
177+
game.tick()
178+
assert game.moves == [(game.slots[2], BR + CB.MOVE)]

‎framework/tests/test_mhfu_port.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -549,19 +549,22 @@ def test_own_moves(lua: Any, tmp_path: Path) -> None:
549549
function mhfu.em_moves_clear() SEAM.moves = {}; return true end
550550
function mhfu.em_play(ent, slot) return SEAM.moves[slot] ~= nil end
551551
function mhfu.em_rule(slot, r) SEAM.rules[slot] = r; return true end
552-
package.loaded.z_moves = { moves = { stamp = { entry = 46 } }, rules = {} }
552+
package.loaded.z_moves = { build = 1, moves = { stamp = { entry = 46 } }, rules = {} }
553553
local P = require("mhfu_port")
554554
P.mod("z_rig", function(P) P.define{ name = "z", species = 75 } end)
555555
P.ports.z.ent = ENT
556556
ticks(1)
557557
assert(not P.ports.z:move("probe"))
558+
assert(select(2, P.ports.z:move("stamp", { build = 2 })) == "stale")
558559
-- a new build's module re-ran in place: the next tick redefines the port from it and re-arms
559-
package.loaded.z_moves = { moves = { stamp = { entry = 46 }, probe = { entry = 47 } }, rules = {} }
560+
package.loaded.z_moves = {
561+
build = 2, moves = { stamp = { entry = 46 }, probe = { entry = 47 } }, rules = {},
562+
}
560563
ticks(2)
561564
local z = P.ports.z
562565
assert(z.ent == ENT and count("moves module changed") == 1)
563566
assert(SEAM.moves[z._slot.probe].entry == 47)
564-
assert(z:move("probe"))
567+
assert(z:move("probe", { build = 2 }))
565568
ticks(2)
566569
assert(count("moves module changed") == 1, "once per new module")
567570
"""

‎packages/mhfu-port/src/mhfu_port/cli/build.py‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def run_build(args: argparse.Namespace) -> int:
4242
out: Path = args.out or Path(m.port.pac)
4343
written = modules(m, built, out.parent, games)
4444
out.write_bytes(built.pac)
45-
print(f"wrote {out}, {', '.join(map(str, written))}\n{built.summary.text()}")
45+
print(f"wrote {out}, {_listed(written)}\n{built.summary.text()}")
4646
return 0
4747

4848

@@ -56,14 +56,18 @@ def run_inject(args: argparse.Namespace) -> int:
5656
path = inject.write_relocate_bytes(
5757
built.pac, host, args.dir, orig=games.fu.read(host), name=m.port.pac
5858
)
59-
print(f"wrote {path}, {', '.join(map(str, written))}\n{built.summary.text()}")
59+
print(f"wrote {path}, {_listed(written)}\n{built.summary.text()}")
6060
return 0
6161

6262

63+
def _listed(written: list[Path]) -> str:
64+
return ", ".join(map(str, written)) or "the modules unchanged"
65+
66+
6367
def modules(m: manifest.Manifest, built: Built, where: Path, games: data.Data) -> list[Path]:
64-
"""Write the port's clips and moves modules into `where`, each whole: the game reloads a
65-
module on change. Raises `ManifestError` for a move the moves module cannot carry, before
66-
either is written."""
68+
"""Write the port's clips and moves modules into `where`, each whole and only when it
69+
changed, since the game re-runs a module that changed on disk; those written. Raises
70+
`ManifestError` for a move the moves module cannot carry, before either is written."""
6771
known = moves.records(games.fu, m.port.host_species)
6872
texts = {
6973
layout.module_name(m): layout.lua(m, built.layout),
@@ -73,6 +77,8 @@ def modules(m: manifest.Manifest, built: Built, where: Path, games: data.Data) -
7377
out = []
7478
for name, text in texts.items():
7579
path = where / name
80+
if path.is_file() and path.read_text(encoding="utf-8") == text:
81+
continue
7682
tmp = path.with_name(path.name + ".tmp")
7783
tmp.write_text(text, encoding="utf-8")
7884
tmp.replace(path)

0 commit comments

Comments
 (0)