Skip to content

Stop a merged cycle allocating a parameter slot it already has - #1252

Merged
Frotty merged 2 commits into
masterfrom
fix/cyclic-merge-parameter-slots
Aug 17, 2026
Merged

Stop a merged cycle allocating a parameter slot it already has#1252
Frotty merged 2 commits into
masterfrom
fix/cyclic-merge-parameter-slots

Conversation

@Frotty

@Frotty Frotty commented Aug 17, 2026

Copy link
Copy Markdown
Member

Functions which call each other in a cycle cannot be emitted as themselves - Jass has no forward declaration - so they are merged into one function taking a choice of which body to run plus the parameters of all of them. A Jass function takes at most 31 parameters. That merged signature was neither counted nor kept small, which is how the compiler comes to emit a function over the limit while nothing in the source is near it.

What was inflating it

Two parameters of the same function are live at once and so cannot share a slot. That is the only constraint, and it was being enforced by searching for a free slot from a position which only moves forwards - which enforces rather more. A parameter matching a slot late in the union puts every parameter after it past everything allocated so far, so it allocates again. The cost lands once per function and accumulates along the cycle.

Three functions taking the same five parameters in different orders:

before: function cyc_third takes integer funcChoice, real a_x, real a_y, integer n, real b_x, real b_y,
                                 string s, real c_x, real c_y, integer n_1, real b_x_1, real b_y_1,
                                 real c_x_1, real c_y_1, string s_1        -- 15
after:  function cyc_third takes integer funcChoice, real a_x, real a_y, integer n, real b_x, real b_y,
                                 string s, real c_x, real c_y             -- 9

n_1, b_1, c_1 and s_1 are slots the third function could not reach. Reordering arguments between two functions which call each other is ordinary, so this is two functions and one swap, not a corner - and six wasted slots for three functions is enough that a longer cycle clears 31.

The change

Remembering which slots the current function has already claimed says the same thing about liveness without imposing an order, and needs one slot per type per position at which some function in the cycle uses that type - the fewest this scheme can use.

Placing call arguments by their parameter's slot, rather than walking parameters and slots in step, is what makes that legal. The old rewriting advanced through the arguments only when the next parameter mapped to the next slot, so it relied on slot indices rising with parameter position - exactly the constraint being removed. Reusing a slot behind the current position without that produces a call with its arguments in the wrong places, which is how I found it.

A tuple is passed as one parameter per component, so the check counts flattened arity rather than parameters; the helper which knew that moved to ImHelper and the vararg pass now shares it instead of keeping its own copy.

What is not fixed

A cycle which genuinely needs more than 31 Jass parameters live at once. With slots shared as tightly as they can be I could not construct one from source, but it is no longer silent: it names the functions in the cycle and says what the count came to. Lifting the limit means passing the excess through an array indexed by recursion depth, which is worth doing once something reaches it.

Tests

CyclicFunctionTests covers both shapes - two functions with one swap, and a three-function cycle - each run for its result and then checked for the emitted parameter count. The count is asserted exactly, not just against 31: the slack was being spent one duplicate slot at a time, and only a long enough cycle made that visible.

Green: CyclicFunctionTests, SimpleFunctionTests, BugTests, OptimizerTests, NewFeatureTests, PackageTests, ConfigPackageTests, DeterministicChecks, LuaTranslationTests, FastHashMapTests, StdLibOwnTests, ClosureTests, GenericsTests, TypeClassTests, VarargTests.

@codex review

Jass cannot declare a function before it is defined, so functions which call
each other in a cycle are compiled into one function taking a choice of which
body to run plus the parameters of all of them. A Jass function takes at most 31
parameters, and that merged signature was neither counted nor kept small.

Two parameters of the same function are live at once and so cannot share a slot,
which is the only constraint. It was being enforced by searching for a free slot
from a position which only moves forwards, which enforces more: a parameter
matching a slot late in the union puts every parameter after it past everything
allocated so far, so it allocates again. The cost lands once per function and
accumulates along the cycle. Three functions taking the same five parameters in
different orders came out at fifteen slots where nine were needed, and a long
enough cycle takes that past 31 while no function in the source is close to it.

Remembering which slots the current function has already claimed says the same
thing about liveness without imposing an order, and needs one slot per type per
position at which some function in the cycle uses that type. Placing call
arguments by their parameter's slot rather than walking parameters and slots in
step is what makes that legal: the old rewriting relied on slot indices rising
with parameter position, which is the constraint being removed. A tuple is
passed as one parameter per component, so the check counts flattened arity, and
the helper which knew that is now shared with the vararg pass rather than
duplicated.

The remaining case - a cycle which genuinely needs more than 31 parameters live
at once - now fails with the cycle named instead of emitting a function the game
rejects at load. Lifting it would mean passing the excess through an array
indexed by recursion depth, which is worth doing once something reaches it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 112a0e8abf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 221 to +224
for (int i = 1; i < newFunc.getParameters().size(); i++) {
ImVar p = newFunc.getParameters().get(i);
if (pos < oldArgs.size() && oldToNewVar.get(oldFunc.getParameters().get(pos)) == p) {
arguments.add(oldArgs.get(pos));
pos++;
} else {
// use default value
arguments.add(tr.getDefaultValueForJassType(p.getType()));
}
ImExpr argument = argumentBySlot.get(p);
arguments.add(argument != null ? argument : tr.getDefaultValueForJassType(p.getType()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve evaluation order when scattering call arguments

When a cycle member's parameter order differs from the merged slot order and its call arguments have side effects, this loop moves the expressions themselves into slot order. For example, with merged slots [int, string], a call to a member declared (string, int) as member(nextString(), nextInt()) becomes merged(..., nextInt(), nextString()); the subsequent flattening pass therefore evaluates the mutations in the opposite order and can change program results. Evaluate the original arguments in their original order into temporaries before scattering those values into merged slots.

Useful? React with 👍 / 👎.

Slots are shared by Jass type, so a class reference and an int are
interchangeable and so are two tuples with the same components. The slot keeps
the IM type of whichever parameter claimed it first, and the other function's
body then reads a variable typed as something else.

That was possible before and is now the common case, because searching a slot
backwards reaches those slots far more often than searching forwards did. It was
the one thing about this change I could not tell from reading it, so it is
covered rather than assumed: every parameter in the added cycle is a mismatch of
that kind, and the values arrive.
@Frotty
Frotty merged commit c92114a into master Aug 17, 2026
7 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant