Skip to content

refactor(Lua): Spring.X -> SpringBucket.X#2799

Open
keithharvey wants to merge 4 commits into
beyond-all-reason:masterfrom
keithharvey:lua-library-types
Open

refactor(Lua): Spring.X -> SpringBucket.X#2799
keithharvey wants to merge 4 commits into
beyond-all-reason:masterfrom
keithharvey:lua-library-types

Conversation

@keithharvey
Copy link
Copy Markdown
Contributor

@keithharvey keithharvey commented Feb 14, 2026

Part of BAR type-error cleanup.

What's it do

Split the monolithic Spring table into SpringSynced, SpringShared and SpringUnsynced. This supports clearer documentation and more easily understood code. The Spring table still exists for back compatibility.

CI IS PASSING!

Also adds a LUA_DOC_EXTRACTOR_BRANCH build arg to the ci in order to allow for lua-doc-extractor forks to be used, so we can test the CI for a new version of Recoil with an experimental version of lua-doc-extractor.
Here is the passing workflow.

Related Work

Motivation

The Recoil engine exposes a single Spring table in Lua, but different contexts (synced vs unsynced) have access to different subsets of functions. These options allow generating SpringSynced and SpringUnsynced type libraries from the same C++ sources without duplicating shared helper types that already exist in the per-file output.

image

@lhog lhog requested a review from rhys-vdw February 15, 2026 01:33
@rhys-vdw
Copy link
Copy Markdown
Collaborator

rhys-vdw commented Feb 16, 2026

Makes Spring a named LuaLS class so it can be used in type annotations and inheritance. All generated function signatures automatically become methods of the Spring class.

AFAIK this is not correct, Spring is a global table, essentially a namespace. There is no way to instantiate something of type Spring.

This allows downstream inheritance for mocks or other LuaLS code comprehension

AFAIK LuaLS should be able to infer that you've extended the table simply by doing:

Spring.foo = 5

Could you tell me more about the problem you ran into? Is this for a test suite?

@keithharvey
Copy link
Copy Markdown
Contributor Author

Hey @rhys-vdw . My goal is a "spring interface", which for my purposes behaves like an EmmyLua class. I want something I can inherrit from to guarantee completeness on my SpringMock when I inherit from ISpring or whatever.

@rhys-vdw
Copy link
Copy Markdown
Collaborator

Hey @rhys-vdw . My goal is a "spring interface", which for my purposes behaves like an EmmyLua class. I want something I can inherrit from to guarantee completeness on my SpringMock when I inherit from ISpring or whatever.

Sorry I'm not trying to push back too hard, but I did consider this exact thing and decided it was not the right approach at the time. I want to be careful about the change, but I'm open to being convinced.

One immediate problem with this is it's going to change the docs to add a Spring class in addition to the global table. It might also show the Spring global table with class Spring, which could be a bit confusing/disruptive. A class implies that it's something you can instantiate, but that's not how at all how the Spring API works—it's a global table.

Does emmylua actually provide the completeness checks when inheriting? It was pretty jank when I was messing around with it.

Have you actually tried adding the annotation? If you have got your whole thing running with the annotation, have tested the docs.

It's annoying that they don't want to implement typeof over there, it would sidestep this whole issue. :-(

Anyway, not completely opposed so long as you've actually tried it and it's working. I would at least want a non-doc comment there explaining why there is a class. Also, is your mock part of Spring or an external project?

@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Feb 26, 2026

nah I need to generate recoil-lua-library and do a post-Recoil-publication version, what I have gotten to work with some decent/surprising levels of edit-time type comprehension, if I jump through the correct hoops, is my own EmmyLua class as ISpring in types/spring.lua over in BAR. This "I" prefix naming might solve our problems around ambiguity here too? Make two independent "interface" EmmyLua classes, ISpringSynced and ISpringUnsynced, and have Spring itself inherit from both? Then we can reflect on the LuaSynced/Unsynced programmatically as part of our build step somehow? Either way, I do think publishing the explicit types is worth it, we could at least verify the mock completeness at test time, even if we're still building it with duck typing (my current mock situation). I'm really just trying to put guard rails in place to make sure this mock gets more useful over time, instead of less useful.

But if we can get away with just telling EmmyLua to express that table as a type with one line, that'd be ideal until we figure out the more complicated and bifurcated pure-interface solution? I'll try to actually generate this and link you the commit diffing the class diff over in BAR and hopefully some red underlines soon.

@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Feb 26, 2026

image

After thinking about this a bit more -- what if instead of one monolithic Spring class, the generator output to SpringSynced/SpringUnsynced based on which source file the functions come from? Spring would inherit from both:

---@class SpringSynced
---@class SpringUnsynced
---@class Spring : SpringSynced, SpringUnsynced
Spring = {}

That way LuaLS could actually catch "calling an unsynced function in a synced context" at edit time, which is a real class of bugs. The file-to-table mapping is already implicit (LuaSyncedCtrl.cpp -> synced, LuaUnsyncedCtrl.cpp -> unsynced), the generator just doesn't express it.

Implementation-wise, the cleanest version would be a CLI option on lua-doc-extractor like --table-mapping "LuaSyncedCtrl.cpp:SpringSynced,..." so the C++ annotations don't need to change. The alternative would be renaming all the @function Spring.X annotations in the C++ to @function SpringSynced.X etc., but that's ~460 lines for what's essentially a config change. I can do the latter in this PR or the former in a lua-doc-extractor PR, open to ideas.

@rhys-vdw
Copy link
Copy Markdown
Collaborator

I tried to keep it as simple as possible so that it's easy to maintain. The only reason that pattern was used for the various callback classes was because it was necessarily to mix and match them.

Since there is no way to type Spring based on file, there is little benefit to that approach. Trust me I've spent a lot of time thinking about this and discussing it. Err on the side of simplicity until features come out that actually solve the problems of the code base.

Adding extra classes is just going to make the docs not make sense, confuse people who are trying to document it, and discourage the many contributors who hate the idea of static analysis.

That said, the Spring class might be fine. I need to read the rest of what you said. Unfortunately you've caught me in the middle of an insanely busy week.

@rhys-vdw
Copy link
Copy Markdown
Collaborator

Actually I've changed my mind, you seem to have your head around it. Just try to keep things simple and consistent, add comments where there might be confusion. Maybe speak to @badosu too.

I'm still pretty sure there's no way to change the type of Spring based on context, but that is the holy grail as you've identified.

@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Feb 26, 2026

--- DELETED because it's now just noise. Basically I used the CI to spit out the buckets I wanted from the source files by just coding the buckets into the CI, as described above. rhys-vdw preferred a more self-expressive decorator pattern similar to what we were already doing, so we came up with the context idea. --

@lhog lhog requested a review from badosu February 26, 2026 12:57
@rhys-vdw
Copy link
Copy Markdown
Collaborator

Sorry haven't had a chance to have a full look at this, but if we're taking this route then it would be better to defined SpringSynced and SpringUnsynced all the way through the codebase and then do this, no?

---@class Spring : SpringSynced, SpringUnsynced
Spring = {}

@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Feb 28, 2026

Yeah agreed. I pushed a commit doing exactly that -- Spring now inherits from SpringSynced and SpringUnsynced directly, with a SpringShared base for the 425 functions that exist in both contexts.

The per-file output still uses Spring.* for now. Going fully "all the way through" means the per-file output would also use the constituent names, which breaks the 1:1 mapping from source files to docs pages. Two options: do it now as a follow-up, or ship this and let consumers migrate to the explicit types first, then graduate them to the primary output in a breaking version. Everyone accesses through Spring anyway so the migration path is just inheritance.

I can see value in keeping the source file categories on the Recoil side, too. Maybe the ideal is forming the input categories (source files) into output categories (Shared/Synced/Unsynced) at the docs level?

@keithharvey keithharvey changed the title Add ---@class Spring annotation to Lua library Synced and Unsynced EmmyLua Classes Mar 1, 2026
@keithharvey keithharvey mentioned this pull request Mar 1, 2026
@keithharvey keithharvey changed the title Synced and Unsynced EmmyLua Classes EmmyLua Synced-Unsynced-Shared Types Mar 1, 2026
@rhys-vdw
Copy link
Copy Markdown
Collaborator

I'm not sure why the library build is failing, it looks as though there is a bug in the parser unfortuanately.

@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Mar 14, 2026

I'm not sure why the library build is failing, it looks as though there is a bug in the parser unfortuanately.

Nah I think I just need to swap out the version of lua-doc-extractor as a separate docker image. The CI pulls from master currently and I didn't really sweat it. Works fine locally in my little test script so ostensibly it would work once https://github.com/keithharvey/lua-doc-extractor/tree/contexts hits main, just need to add a build_arg to the CI/docker over here [probably as a separate tiny PR]

@rhys-vdw
Copy link
Copy Markdown
Collaborator

rhys-vdw commented Mar 14, 2026

The CI pulls from master currently and I didn't really sweat it.

Oh that's bad. I have been releasing it on npm. I have some vague memory that there was a problem installing it from there, but now I'm not sure.

Edit: or do you mean CI is pulling Recoil from master instead of the PR branch? No it's not

@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Mar 15, 2026

Still haven't fixed the CI, but I did finish the @context refactor! @rhys-vdw check it out. I like the docs but let me know what you think (added a screenshot to the description)!

Comment thread rts/Lua/LuaSyncedRead.cpp
@keithharvey
Copy link
Copy Markdown
Contributor Author

Rebased on #2868 -- can always revert if that doesn't go but i need it for a unified workflow right now.

@keithharvey keithharvey force-pushed the lua-library-types branch 4 times, most recently from 8bc2619 to 7d9b121 Compare March 17, 2026 06:07
@keithharvey keithharvey force-pushed the lua-library-types branch 3 times, most recently from 98893c0 to a884e58 Compare March 23, 2026 22:48
@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Mar 30, 2026

The AST transform (keithharvey/bar#28) correctly maps every function to exactly one type

@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Apr 15, 2026

The build is failing because the submodule commit does not exist on origin, it works if you wire up the upstream.

Quick update but I went ahead and just changed the docs to be SpringBucket instead of Spring for consistency after talking with @rhys-vdw . This greatly simplified the lua-doc-extractor side of things.

@keithharvey keithharvey changed the title EmmyLua Synced-Unsynced-Shared Types refactor(Lua): Spring.X -> SpringBucket.X Apr 15, 2026
@sprunk
Copy link
Copy Markdown
Collaborator

sprunk commented Apr 16, 2026

Nobody asked for this. Get buy-in from gamedevs first.

Adding missing function annotations looks good, could put that in #2888.

Copy link
Copy Markdown
Collaborator

@rhys-vdw rhys-vdw left a comment

Choose a reason for hiding this comment

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

Just noticed a couple of things

Comment thread rts/Lua/LuaSyncedCtrl.cpp Outdated
Comment thread rts/Lua/LuaSyncedCtrl.cpp Outdated
Comment thread rts/Lua/LuaSyncedCtrl.cpp Outdated
@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Apr 24, 2026

Nobody asked for this. Get buy-in from gamedevs first.

Adding missing function annotations looks good, could put that in #2888.

@sprunk

I guess who do I need to convince? It's backward compatible, it immediately supports explicit type comprehension and mocking in BAR and other projects -- I guess I'm missing the reasons not to merge this?

@sprunk
Copy link
Copy Markdown
Collaborator

sprunk commented Apr 24, 2026

The code itself is backwards compatible but the, let's say "direction" is not. If this is to become a standard then everybody would have to follow it. If you're fine with this being done just for BAR you can do it locally (via SpringUnsynced = { ... subset of Spring ... }), otherwise you should convince devs of Recoil games other than BAR.

@keithharvey
Copy link
Copy Markdown
Contributor Author

keithharvey commented Apr 24, 2026

Totally fair. Did you see my RFC for requiring BAR-Devtools? I think that one is already plenty big but I could do something similar for this idea. I get that it's out of left field, but I do think it's a technical improvement after staring at it for a bit here. Hmm maybe it belongs in that document as "Decision 6"..

@sprunk Would you support it as a BAR-only decision, for now, since the conversion work there is already done? To demonstrate the usefulness over in ZK/Recoil later? I'm honestly not sure I would. It loses a lot of the engine-support we get by having it publish SpringSynced for our mocks to have comprehension on to ensure we don't drift.. that's the real win here. Real mocks and edit-time comprehension.

@sprunk
Copy link
Copy Markdown
Collaborator

sprunk commented Apr 28, 2026

you should convince devs of Recoil games other than BAR

@keithharvey keithharvey force-pushed the lua-library-types branch 3 times, most recently from 3aada55 to b22d9e0 Compare April 29, 2026 02:27
Series of minor EmmyLua annotation corrections and small documentation fixes that accumulated during the spring-split exploration. None of these depend on or relate to the spring-split refactor; pulled out as a standalone PR to keep the spring-split review focused.
Splits the engine's `Spring` Lua API into three context-specific tables:

- `SpringSynced`   — gadget-only (mutates game state, deterministic)
- `SpringUnsynced` — widget-only (visual/UI, free to be non-deterministic)
- `SpringShared`   — both contexts (read-only utilities + pure functions)

Each Lua-exposed engine function is registered into exactly one of them.
Per-context Lua environments expose only the relevant tables: gadgets
see `SpringSynced` + `SpringShared`; widgets see `SpringUnsynced` +
`SpringShared`. The legacy `Spring` table is retained as a deprecation
alias so existing code keeps working during the transition window.

Background and full RFC (audience: Recoil engine maintainers + Recoil-based
games like Zero-K, BYAR-Chobby): see the design doc shared alongside this PR.

Migration:
- The `Spring.X` deprecation alias keeps existing code working; new code
  should use the namespaced form.
- Downstream games can opt into the namespaced form on their own schedule;
  BAR has done so via its `mig-spring-split` codemod, reusable from
  beyond-all-reason/BAR-Devtools.
Two follow-ups to the SpringSynced/Unsynced/Shared split that the
language server caught (`mise run lua_check` from doc/site).

1. Declare the three split namespaces as ---@Class in Spring.lua.
   The split inheritance line `Spring : SpringShared, SpringSynced,
   SpringUnsynced` referenced types that weren't declared anywhere,
   so every generated `SpringSynced.X = nil` / `SpringUnsynced.X = nil`
   assignment fired "undefined global" warnings.

2. Re-add `@function SpringUnsynced.GiveOrderTo*` annotations on the
   GiveOrderToUnit family in LuaUnsyncedCtrl.cpp. The split commit
   had dropped the original `@function Spring.X` lines without
   replacing them, leaving the surrounding `@param` annotations
   orphaned (each fired "undefined param").
Copy link
Copy Markdown
Collaborator

@rhys-vdw rhys-vdw left a comment

Choose a reason for hiding this comment

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

Some notes. For some reason I didn't get the "suggest" button or else I'd have used that feature so you could just accept them all.

Object rendering API — controls LOD, materials, and custom draw for units/features.
Registered as `Spring.UnitRendering` and `Spring.FeatureRendering`.
@see rts/Lua/LuaObjectRendering.cpp
*/
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

FYI freefloating comments like this are ignored by lua-doc-extractor because they're not attached to anything. Probably appropriate to include with @class ObjectRenderingTable.

Comment thread rts/Lua/LuaFonts.cpp
* @function LuaFont:SetOutlineColor
* @param color table Four-component RGBA array (`{r, g, b, a}`), or pass `r`, `g`, `b`, and optional `a` as separate numbers (requires at least three numeric components after the font).
*/
int LuaFonts::SetOutlineColor(lua_State* L) { return (SetTextColorShared(L, true)); }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Pretty sure this should be expressed as an overload (same as method above). Doesn't require a lengthy description.

/*** Sets the RGBA color used for text outline when outline rendering is enabled.
 *
 * @function LuaFont:SetOutlineColor
 * @param color rgba Outline color
 */
/*** Sets the RGBA color used for text outline when outline rendering is enabled.
 *
 * @function LuaFont:SetOutlineColor
 * @param r number Red
 * @param g number Green
 * @param b number Blue
 * @param a number? Alpha
 */

Comment thread rts/Lua/LuaPathFinder.cpp Outdated
/******************************************************************************/

/***
* @function Spring.RequestPath
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should these not live in one of the SpringX buckets?

Comment thread rts/Lua/LuaSyncedCtrl.cpp
* @function Spring.SetUnitHarvestStorage
* @param unitID integer
* @param metal number
* @param metal number?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

What would it mean to pass null here? Would be good to add a (Default: <whatever>). Hard to infer what this function is for without a value being passed.

Comment thread rts/Lua/LuaSyncedCtrl.cpp
* @param dgun boolean? (Default: `false`)
* @param userTarget boolean? (Default: `false`)
* @param weaponNum number? (Default: `-1`)
* @param dontForceTarget boolean?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Comment thread rts/Lua/LuaSyncedCtrl.cpp
* @param dgun boolean? (Default: `false`)
* @param userTarget boolean? (Default: `false`)
* @param weaponNum number? (Default: `-1`)
* @param dontForceTarget boolean?
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Comment thread rts/Lua/LuaSyncedRead.cpp
* @param unitID integer
* @param allyTeamID integer?
* @param raw false? Return a table.
* @param raw false? (Default: `false`) Return a table.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is kind of confusing. I think the description should be Return a bitmask, since false implies that it would not return a table. If true is passed here then it will automatically use the overload which does return a bitmask.

/*** @function Spring.SendLuaUIMsg
* @param message string
* @param mode string "s"/"specs" | "a"/"allies"
* @param mode string? "s"/"specs" | "a"/"allies"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@param mode ("s"|"specs"|"a"|"allies")?

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.

3 participants