WIP: feat(cheats): save the game where Link stands, from the Cheats page, the control bridge and test input #461
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/tooling
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8015c9a
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
risk
high
risk
low
risk
medium
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!461
Loading…
Reference in a new issue
No description provided.
Delete branch "HumanBean/Longshot:feat/cheat-save"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Type: feat · Risk: medium · Tested on: nothing yet (not built or run) · Needs: Linux and Windows (llvm-mingw), any GPU
Why
Test runs start from a fixed save and spend about 70 s reaching their scene (an opening event, then a teleport), and making a save at a spot today means scripting blind presses through the pause menu. Save here saves the game where Link stands, from the Debug tab, the control bridge or a live input file, so a test (or a player using the debug tools) can make a save at any spot and start from it.
No issue asks for it: the project owner asked for it. The tracker was searched (save, slot, save state, cheats, debug tools, teleport) and nothing fits; #259 (user profiles and save folders) is a different feature.
Nothing in this PR was built or run: no build, no game session, no test suite (the project owner asked for the code and a WIP PR first: "do the code and open pr, don't test it yet"). It is untested.
What changed
A new Save here row on the Debug tab (and the same action from the control bridge and a live input file) plays the game's own pause-menu save for you: Plus, System, Save, Up, A, then B until the game is back in play. It is a host-side input macro, not a call of the game's save routine, so the save is exactly the game's own, in a new slot; the log and the bridge say which slot was written.
Approach: a host-side input macro, not a call of the game's save routine. The repository has no note or address for the routine the pause menu's Save runs, and the existing tools call only game functions whose arguments and checks are known (
oot_teleport,oot_set_time, the pouch edits), so no address is guessed. What the macro plays is the sequence verified by scripted input in the Castle Town Market: Plus (pause menu, Items tab), R (System), A (Save: the slot card, Cancel chosen), Up (Save), A. That wrotesave/<user>/slot_03/progress.sav, which then loaded at that spot. The direct approach would still need one profiled run that names the caller ofprogress.sav's write (nn::fs::WriteFile/CloseFilefrom the pause menu's Save), then a check of its arguments the wayoot_pouch_changeandoot_teleportcheck theirs.host/hos_main.c,oot_save_*, inside the debug tools'#if defined(_WIN32) || defined(__linux__)): action 998 (OOT_SAVE_ACTION, next to the age's 999; below the teleport's 1000+, above the pouch's 200-348). It is taken at Link's update (oot_update_player_cheats) like the other actions and is refused, with nothing pressed, while an event plays (oot_event_playing: cutscene, conversation, map change), a map loads (Sequence_IsLoadingBanc), no save is loaded (the pouch count is -1, as the Inventory page's), the free camera is on or Link is flying (they take L, R and B), or a save is already under way. The presses then go one at a time throughhos_hid_tap, the queue the live input file's lines go through, from the controller's read (hos_oot_save_tick, called innn_hid.c scripted_inputbeforehost_taps). The waits are counted in the game's presents (hos_present_count, 180 = the verified 3 s), not wall time. Checks along the way:progress.savmust be written within 10 s of the last A;One log line at the end:
cheats: save: saved in slot_NN,cheats: save refused: <why>orcheats: save not made: <why>, after acheats: save: through the pause menu ...line when it starts.host/nn_fs.c,note_save_write,hos_fs_save_writes): aprogress.savin the save folder that is closed after a write (CloseFile) or renamed into place (RenameFile) is counted, with its folder's name. The log and the bridge use it to say which slot was written. It is generic code, so it runs for every game but only does something for a file namedprogress.savunder the save mount.host/control.ccmd_save/save_done,host/control.hsave,save_result,save_cancel):{"cmd":"save"}answers once it is over with"saved":true,"slot":"03"(and anoteif it saved but the menu stayed open),refusedorfailedwith the reason,timeout(not taken in play, so it is withdrawn; or still under way), orcancelled."wait":falseanswers at once. The default timeout is 60 s. Client:longshot_ctl.py save [--timeout S] [--no-wait](scripts/longshot_ctl.py).gpu/vk/vk_menu.cpp): a Save here row (Row::cheat12, button "Save") at the end of the Debug tab's Travel group, after Teleport and Mark/Return. Confirm stores 998 and closes the menu, as Switch age does.kCheatRows,debug_tools_orderanddebug_groupschange to fit it. There is no translation of the new row's texts yet: the existing texts are unchanged, so no table goes stale.tests/test_cheat_save.pycuts the save's code out ofhos_main.cand runs it against a fake pause menu and fake game data. It checks the sequence and slot, that nothing is pressed while the Insert menu is open, each refusal, a pause menu that doesn't open (it stops after Plus), no slot written (the menu still gets closed), and the bridge's tickets. It also checks the wiring in the sources. Updated:tests/control_test.c(a fake save, and replies: saved with the slot, refused, timeout with the ask withdrawn, no wait),tests/test_longshot_ctl.py,tests/test_damage_debug_menu.py(the Debug tab's order and groups),tests/test_menu_keys.py,tests/test_input_background.py(a stub for the tick itsscripted_inputnow calls),tests/test_debug_tools_linux.py. None of them was run.docs/control.md(thesavecommand, its errors, a Known limit),docs/development.md(!cheat 998), the README (the Debug row list, a paragraph on Save here).Ways to trigger it:
{"cmd":"save"}(orpython -I scripts/longshot_ctl.py save);HOS_INPUT_LIVE=<file>): a line!cheat 998.Loading a slot at start: the title's Continue loads the newest slot (docs/control.md,
saves:newestmarks the one Continue loads), andlongshot_ctl.py launch --save FOLDER --slot NNalready copies one slot alone so that Continue loads it. This PR adds no slot picker.Reviewer notes: read
oot_save_tickinhost/hos_main.cfirst (the steps and their checks), thenoot_save_start(the refusals) andnote_save_writeinhost/nn_fs.c. Left for later on purpose: calling the game's save routine directly (needs one profiled run, see above), a slot picker at start, and translations of the new row's texts.Measurements
Verdict: not measured yet: nothing was built or run. It is no performance change; the measurement a feat needs (Save here working in the game, and no cost with it unused) is still to do.
Before: main @
26a2eab0(2026-11-14, the merge base); after: this PR, feat/cheat-save @16976033.Hardware: none yet (not run)
Scene: none yet (not run); the one to use is the Castle Town Market (child), standing still
Method: none yet: no build, no game session, no test suite
How to reproduce: see Must be tested under Testing
26a2eab016976033Screenshots: none yet, nothing was run. The change shows on screen as one new Debug tab row (Save here, button "Save").
Flags and settings
No new environment switch, ini key or build flag: one new Debug tab row, one new live input action and one new bridge command (with its
longshot_ctl.pysubcommand). The row stores no setting (nolongshot_options.inikey).HOS_INPUT_LIVEline!cheat 998(new action)save(new),longshot_ctl.py save [--timeout S] [--no-wait]HOS_CONTROLis set; default timeout 60 ssavedwith the slot,refused,failed,timeoutorcancelledTesting
Tested: nothing in the game or the test suite. No build, no game run and no test suite was run on this PC for this PR (as asked). The code was only read: the macro, the bridge's tickets and the fixtures were checked by reading, against the verified menu sequence and the existing teleport and age paths. The Test Farm was asked (
@Beanstalk, 2026-11-14 19:12) and started a plan for commit1697603on linux-nvidia and linux-amd (the existing pause-menu save in the child Market, main against this PR: crashes, back in play, a picture after saving); at the time of this edit both were still queued, with no report. Its plan says it cannot press Save here or check and reload the written slot, so even when it finishes it does not test the new feature.Not tested: all of it, nothing having been built or run. Things to watch, where the macro assumes something not yet seen: that Link's update stops while the pause menu is open (the check after Plus relies on it, and docs/control.md says the bridge's samples stop then); that the card's cursor is on Cancel every time (a second save in the same session: the probe's second A saved again, so the cursor may be remembered as Save, and then Up might do nothing or move off it); that two B presses close the card and the menu; that a hold-toggled R (Hold to toggle) doesn't stay latched afterwards.
Must be tested:
python -I -B tests/run_all.py, expecting it to build and the newtests/test_cheat_save.pyand the updated checks to pass (by hand, or Forgejo's CI: the build log and the test summary)slot_NNbeside the others; the log linecheats: save: saved in slot_NN; the game back in play (the pause menu closed by the B presses); then restart and Continue, expecting Link to start at that spot (by hand: the log, the save folder and the spot after Continue)longshot_ctl.py slots(orsaves), expecting the slot in the log line and the bridge's reply to be the one shown as newest (by hand)longshot_ctl.py savein play, expectingsaved in slot NN; during a conversation or a cutscene, expectingrefused: ... an event is playingand nothing pressed; on the title or file select, expectingtimeout(not taken); two quick asks, expecting the first onecancelled(by hand: the replies and the log)!cheat 998in anHOS_INPUT_LIVEfile, expecting the same save (by hand: the log line and the new slot)savereply, the slot written and Continue loading it)Risks
What could break: presses landing in play if the game isn't where the macro expects it (a pause menu that didn't open, the card's cursor not on Cancel, a menu left open). Against that, it presses nothing until the Insert menu is closed and Link is in play, it stops after Plus if the pause menu didn't open, and it stops pressing B as soon as Link is updated again. Otherwise inert unless asked for: the tick returns at once while no save is under way, and the
nn_fs.chook only counts files namedprogress.savin the save folder (generic code, so it runs for every game, but does nothing else).Turning it off: don't use the row, the
savecommand or!cheat 998(the row is shown only while Debug tools is on, off by default); reverting the commit removes it.Compatibility: saves: Save here writes a save, but it is the game's own save into a new slot each time, so it never overwrites an existing slot, and existing saves are read as before; the
nn_fs.chook only notes whichprogress.savwas written and changes no file.longshot_options.ini: no new key (the row stores nothing). The shader and pipeline caches, the vk ABI and the builder's tools: unchanged. The control bridge gains a command; its other commands are unchanged.Checklist
Everyone
type(scope): description) of the status line's type.main. (Not yet: the branch is on main @26a2eab0; main is now at7ff5256e.)python -I -B tests/run_all.pypasses (any failure that also fails onmainis named). (Not run, as asked: left to Forgejo's CI and the first test run.)third_party/untouched (a new folder with aPROVENANCE.mdinstead).When it applies
Assisted-by:/Co-Authored-By:trailers.tests/that reads changed source updated in this PR, not weakened.__atomicas the neighbouring code uses,_stricmpasnn_fs.calready does, no new path conversion.)Test runs start from a fixed save and spend about 70 s reaching their scene; a save made at the scene removes that, and making one today means scripting blind presses through the pause menu. Save here (Debug tab, Travel; action 998: the bridge's save command, a live input file's "!cheat 998") plays the game's own pause menu, the sequence verified in the Market: Plus, R (System), A (Save: the slot card), Up, A. It is a host-side input macro, not a call of the game's save routine, which is not known from the repository (one profiled run, the caller of progress.sav's write, would name it). The presses go through hos_hid_tap, the live input's queue, one at a time from the controller's read, 180 presents apart. Taken at Link's update and refused during an event, a load, with no save loaded, with the free camera or the flight on, or while a save is under way. Nothing is pressed until the Insert menu is closed; if Link is still updated 3 s after Plus (the pause menu did not open) it stops there. nn_fs.c counts progress.sav files written into the save folder with their slot folder, so the log line and the bridge's reply name the slot; B then closes the menu (at most four times, until Link is updated again). The bridge's save replies saved and the slot ("03"), refused or failed with the reason, or timeout; longshot_ctl.py save. Tests: tests/test_cheat_save.py compiles the save's code against a fake pause menu; control_test.c and test_longshot_ctl.py get a fake save; the Debug tab's order, the menu keys, the input fixture's stubs and the Linux debug-tools check are updated. Nothing was built or run. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXnDescription: the Measurements' main and branch lines in the form the description check reads (main: (), this PR: @ ). Nothing else changed; still nothing built or run.
/beanstalk
@Beanstalk
@HumanBean authors can't approve their own PR; another maintainer has to.
@Beanstalk
Beanstalk test farm
Commit
1697603· tier reviewed · approved by HumanBean · dashboardWhat will be tested · AI plan (
gpt-6.1-terra)The plan compares the existing pause-menu save flow in the child Market on main and the pull request, taking about 13 minutes per tester. It checks for crashes, a return to gameplay, and a visual difference after saving. The plan cannot activate Save here or verify the written slot and reload it, so those new behaviors remain untested.
Started.
NVIDIA GeForce RTX 4070, 615.71.9Measurements · linux+nvidia
Verdict: could not be judged on linux+nvidia (1/1 report).
Hardware: 1: linux+nvidia+nvidia-proprietary+dgpu
NVIDIA GeForce RTX 4070, 615.71.9AMD Ryzen 7 5800X3D 8-Core Processor, 63 GB RAMScene: market-manual-save, from a new save; each cell's settings and switches as the table names them
Method: the Beanstalk test farm: main and this PR built and run on the same PC in one session, game starts alternating (ABBA); market-manual-save: 1 game start a side; each number the median of the runs, then of 1 report
How to reproduce: the farm's plan above (
@Beanstalkon this PR runs it again)Nothing was measured: no metric came back.
Flags and settings
None: the plan varied no setting, switch or build option.
Markdown for the PR description
@Beanstalk
@HumanBean
1697603is already running: see the dashboard.View command line instructions
Manual merge helper
Use this merge commit message when completing the merge manually.
Checkout
From your project repository, check out a new branch and test the changes.