fix(lift): a function with code below its entry starts at its entry #131
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
good first issue
gpu/amd
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/steam-deck
platform/windows
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!131
Loading…
Reference in a new issue
No description provided.
Delete branch "snaildev99/Longshot:fix/lift-entry-below"
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?
Why
The translator starts each of the game's functions at its lowest address. For 26 functions of the game that is not their first instruction, so they were started in the wrong place. A mod's code patch that is built in with the game runs into the same thing: the game crashed as soon as the mod's menu was used.
What changed
nx2/lift_fixes.py, fix 4, wrapsLifter.emit_function. a64-kit'semit_functionwrites a function's instructions in address order, and the C function began at the first of them. A function is its entry and everything reachable from it without crossing another entry, so code it branches to at a lower address, which is no entry of its own, came first. When the lowest address of a function's body is not its entry, the entry now becomes a label and the C function starts withgoto L_<entry>;. Every other function goes through a64-kit's code alone.nx2/lift.pyprints how many functions were started that way.tests/test_lift_entry_below.py, game-free, without the translator or a compiler: a realLifterover a made-up image.The 26 functions on build
e0f3ccfa:bltargets, data pointers and jump table targets, so one that branches to a lower function which nothing calls has that function below its entry.setjmpcall (an entry sincefind_setjmp_owners); the code after the call loops back above it.I do not know that any of the 26 runs in the port today, and I have not seen the unmodified game misbehave because of this. For the unmodified game this is a correctness fix without a known symptom.
The 26 entries
main+0x165ab04 main+0x24a4704 main+0x24a686c main+0x24a75a8 main+0x24a7a9c main+0x24a7d34 main+0x24b3ff0 main+0x24b5de0 main+0x24b7b58 main+0x24bd330 main+0x24c2324 main+0x24c66b4 main+0x24c8130 main+0x24c89c0 main+0x24ca5d8 main+0x24cc674 main+0x24ceb6c main+0x24cf200 main+0x24d06e0 main+0x24d07f8 main+0x24d0830 main+0x24d0868 main+0x24d08c8 main+0x24d0900 main+0x24d29a4 main+0x24d4454
Flags and settings
None.
Measurements
main:
341d63a4(2026-11-13)this PR: fix/lift-entry-below @
3e0e891f(rebased onto main @341d63a4; the files of this PR and the lift's are unchanged between the two mains, so the numbers hold)Hardware: RTX 3090, Windows 11
Scene: none (a fix in the lift: the proof is a test, the lifted C, and a crash gone)
Method: the new game-free test with main's
nx2/lift_fixes.pyand with this PR's; the lifted C of the unmodified game with and without the fix; one build played with a code modHow to reproduce:
python -I -B tests/test_lift_entry_below.py; for the lifted C,python -I nx2/lift.py --standalone --writeinto two work folders and comparesa/gen341d63a43e0e891ftests/test_lift_entry_below.py(the test file of this PR)goto L_<entry>;lines and 20 labels added, no line removed or changedThe lifted C and the played build are from v2.2.0-rc.1's lift, not from main @
341d63a4: itsnx2/lift_fixes.pyandthird_party/a64-kit/lift/a64_lift.pyare the same files as main's (compared), and the fix was there in this PR's form (the lifted C of the played build is the same, file for file, with it).lr 0x83c66ff8is in the mod's code cave after main's text: the lifted routine began at its lower part (0x83bd5390), without its first instructions, and called a game function with the wrong argument.Testing
Tested:
python -I -B tests/run_all.pyon Windows 11: 457 passed, 70 skipped, 10 failed. The same 10 fail on main @341d63a4on this PC:test_builder_details_hitbox,test_builder_dlss_update,test_portable_archive,test_portable_builder_home,test_portable_builder_startup(no .NET SDK here),test_fpx_sticky,test_spawn_emit(they build the translator; the builder's llvm-mingw copy has no C++ standard headers),test_check_third_party,test_release,test_setup_runtime.Not tested:
341d63a4itself (the lift files are the same as the ones tested).goto.Needs other testers: None
Screenshots
Nothing on screen.
Risk and rollback
The 26 functions now run from their entry, as the game's code does; if one of them is in use and something relied on the old start, it would show there (libcurl, and one
setjmpreturn). Reverting the commit is enough.Reviewer notes
lift_fixes.install(), beforepoll.install()wraps the emit (nx2/lift.py's order). The goto to the entry is a forward one, so the polls are the same (the test checks it).L_<entry>:;itself.Issues
See #42
Checklist
For everyone
type(scope): description).main.python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above).third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.mainand passing here.🤖 Generated with Claude Code
The description's Testing section fixed (no other testers needed). The branch is on main @
341d63a4.WIP: fix(lift): a function with code below its entry starts at its entryto fix(lift): a function with code below its entry starts at its entryReviewed the diff and the code around it: a64-kit's
walk/emit_function/transfer, thedead_flagsanddirty_regspasses,localize_regs,nx2/poll.pyand the spawned emit innx2/chunks.py. I didn't run anything.The fix is right for the lift as it is built by default. A function's body can reach below its entry:
walkfollows every branch that doesn't land on another entry.emit_functionwrites it in address order and enters atpcs[0]. Making the entry a label and jumping to it is the smallest change that fixes this. Some things I checked:dead_flagsis a backward liveness pass, anddirty_regsstarts every instruction clean and joins over predecessors. Neither assumes index 0 is the entry, so the leading goto changes neither result.transferalready writes agotofor a branch toefrom inside its own body (t == self.cur). Witheinlabels, the base now writes the label those gotos need.add_pollsstill finds the same back-edges.lift.py's module level again, and_identities()checks they did.entry_gotoscomes back through thelifter.statsdeltas, so the countlift.pyprints is right there too.min(body).install()is guarded, so the wrapper is never added twice.third_party/is untouched, the fix is innx2/as CONTRIBUTING asks, and the new test runs without the game. It has teeth: the unwrapped emit is captured beforeinstall().One inline comment on where the goto goes when
A64_LOCAL_REGS/A64_LOCAL_FLAGSis set. Both are off by default ("experiment" in a64-kit), so the default build isn't affected, but under them the goto would skip the locals' initializers. A follow-up is fine, as long as it doesn't get lost.Approving.
@ -115,0 +132,4 @@labels.add(e) # (the base writes "L_<entry>:;" at an entry that is a label)text, refs = base_emit_function(self, e, texts)head = "void f_%08x(Ctx *__restrict A64_NOESC c) {\n" % eat = text.index(head) + len(head)With
A64_LOCAL_REGS(orA64_LOCAL_FLAGS) set,localize_regshas already put the locals' declarations on the line right after the signature (uint64_t x0_ = c->x[0], ...;/uint8_t fn_ = c->n, ...;). Inserting the goto right afterheadtherefore jumps over their initializers. C allows that jump: no error, and no warning from clang by default. Every local register would then start indeterminate in these 26 functions. Both switches are off by default, so today's build is fine, but this would be a silent miscompile for whoever tries the experiment.A cheap fix: put the goto after the declarations instead, e.g. skip the lines after
headthat start withuint64_t x/uint8_t fn_. Or put it right before the first line of the body. A case intest_starts_at_its_entryundermock.patch.dict(os.environ, {"A64_LOCAL_REGS": "4"})would cover it, since the test's texts already usec->x[0].