Modules
Every Lua file in the mod, what it is responsible for, and the functions other code relies on.
Paths are relative to mod/CrewSwap/.
Package files
| File | Purpose |
|---|---|
filelist.xml | The content package: name="CrewSwap", modversion, gameversion="1.13.4.0", corepackage="false", and one <Item> entry for the sync file. modversion is stamped by tools/package.sh. |
ModConfig.xml | Two autoruns: Lua/crewswap/server/main.lua with Target="Server" and Lua/crewswap/client/main.lua with Target="Client". Because a ModConfig.xml exists, LuaCs skips the legacy Lua/Autorun folder scan. Everything else is reached through require. |
Content/crewswap_sync.xml | An empty <Items></Items> file. Lua files do not count as multiplayer-synced content, so without at least one synced file the server would neither send the mod to clients nor require it. An item file is synced, and an empty items root creates no prefabs and logs no errors. |
All modules are namespaced crewswap.* because the require cache is shared by every mod.
shared/
shared/build.lua
Returns { BUILD, MODVERSION, EXPECT_GAME, EXPECT_LUACS }.
BUILDis the exact build string. The client sends it in its hello and the server compares it for equality, so server and clients must run copies from the same package.MODVERSIONis written into the save’s<CrewSwap>element.EXPECT_GAME('1.13.4') andEXPECT_LUACS(a short LuaCs commit hash) are what the version gate compares against. A game mismatch turns the mod off; a LuaCs mismatch only logs a warning.
In the repository, BUILD and MODVERSION hold the current release values ('0.9.0-beta' /
'0.9.0'). tools/package.sh <version> [suffix] re-stamps both, plus modversion in
filelist.xml, in a staged copy, so the zip always carries the version you pass.
core/: pure decision modules
No game globals, no side effects beyond their own tables. Each has a matching tests/test_*.lua.
core/version.lua
compare(found, expect)→ok, reason, warning. The game matches when it equalsEXPECT_GAMEor starts withEXPECT_GAME .. '.'(so1.13.4.2matches but1.13.40does not); a game mismatch returnsfalse, 'game <found>', and takes precedence (no warning). LuaCs is compared on the first 7 characters of the commit hash: a different, short, empty or unreadable commit returnstrue, nil, warning, wherewarningis the line[CrewSwap] WARNING: built for LuaCs <EXPECT_LUACS>, running on <found or 'unknown'>; continuing (patch audit and self-tests still guard). A full match returnstrue, nil, nil.
core/rules.lua
The switching rules.
COOLDOWN = 3(seconds between switches per player).allowedByToggles(view, toggles, requesterKey): protect on hides other players’ homes;ownpool allows only bodies the requester owns, their own home, or bodies that nobody owns and that are nobody’s home (the first player to take one claims it).isFree(view, toggles, key): crew, an AI body, alive, conscious, not removed, no controller, and allowed by the toggles.validate(req, view, toggles, ctx)→ok, reason. Reasons in fixed order:inactive,round(not running or ending),joining(not in game or not synced),nohome(no campaign character yet),spectating,traitor,dialogue,cooldown,invalid(not crew),taken,unavailable,not-yours. The requester’s own body state is deliberately ignored, so a downed player may still switch out.message(reason, extra): the player-facing[CrewSwap] ...text for a reason (also used on the client, so both sides word denials identically). It also words thetakeoverdenial, which is not avalidatereason: theTAKEOVERBOTrouting inserver/death.luasends it for a hire or bench id outside the Permadeath case.
core/registry.lua
Session-to-stable-id bindings and per-character records.
new()→ registry withbySession(CharacterInfo.ID→ sid) andchars(sid → record).R:bind(sessionId, sid),R:sidOf(sessionId),R:record(sid)(creates on demand),R:prune(liveSessionIds)(drops stale bindings, keeps records).FIELDSmaps record fields to XML attributes:last,owner,home,wbal,wrd,lastAt→crewswap_last,crewswap_owner,crewswap_home,crewswap_wbal,crewswap_wrd,crewswap_lastat.NUMERICmarkswbal,wrdandlastAt.ID_ATTR = 'crewswap_id'.encodeAttrs(rec)/decodeAttrs(map): record ↔ attribute strings.campaignElement(state)/parseCampaignElement(map): the<CrewSwap campaignguid modversion>element.
core/mirror.lua
Re-matching after a Lua reload or a period with the mod off.
rowAttrs(row)/parseRow(get): one<c>row of the mirror file (id,on= original name,h=GetIdentifierUsingOriginalName()hash,job, plus the record’screwswap_*fields).match(entries, live)→{ [sessionId] = sid }. Pass 1 pairs entries and live infos that share a unique hash; pass 2 does the same on original name for what is left. Anything ambiguous stays unmatched, so every sid is claimed at most once and the result does not depend on list order.
core/placement.lua
Where a player goes at round start or on rejoin.
lastPlayed(chars, key): the sid whose record haslast == keyand the greatestlastAt.roundStart(players, views, toggles)→ list of{ key, targetId }moves. With protect on, nobody moves. With protect off, each player goes to the character they last played if it is Free for them, is not their home, and nobody earlier in the list claimed it.rejoin(player, views, toggles)andrejoinTarget(...): the same rule for one rejoining player, falling back to their own home body when that is Free for them (a rejoiner whose body was handed back to the AI otherwise has no character).
core/grace.lua
The crew-down grace timer: GRACE = 120, WARN_AT = 90, and three chat lines (start, 30 s left,
end). grace.new() returns a state with step(dt, vanillaDown, anyFree) → mask, announce.
While vanilla would end the round and Free crew exists, mask is true for up to 120 s.
core/integrity.lua
The storage check over plain lists of sids.
snapshot(stores): copies the CCD and roster lists and recordsstalesids (registry records for characters already gone at the snapshot, i.e. removals an earlier round accepted).check(startSnap, nowStores, legit)→ok, problems. Problems are sorted strings:duplicate <sid>,missing <sid>,moved <sid>,unknown <sid>.legitlists removals made on purpose this round (permadead,fired,discarded) andadoptedbots that legitimately moved from roster to CCD. See Safety.
core/revive.lua
What happens to a dead crew member at round end.
plan(info, path):info = { cause = 'none'|'disconnected'|'other', permadeath, inCCD, inRoster },path = 'transition'|'saveandquit'|'unsaved'. Returns flagskeep,penalty,clearHealth,clearInventory,clearOrders,pendingSpawn. Only non-home roster members outside Permadeath that actually died are kept. The skill penalty applies only when the round saves and the death was not a disconnect. The inventory is cleared only on a level transition.dropsAtSave(body): whether vanillaSavePlayersitself removes this body’s info (a dead bot with an inventory that did not die of a disconnect).
core/commands.lua
The admin chat command and config file.
PREFIX = '!crewswap',DEFAULTS = { pool = 'shared', protect = true }.parse(text)→nil(not ours) or{ op = 'status'|'check'|'pool'|'protect'|'owner'|'error', ... }. Tokens split on whitespace with"double quoted"parts kept whole;!crewswapperis not a match.configEncode(t)/configDecode(s): thepool=.../protect=on|offtext format; unknown lines and bad values fall back to defaults.statusText(t, info),FAIL_NOTICE, andfindByName(list, name)(case-insensitive exact match, then a unique prefix;'none'or'ambiguous'otherwise).
core/statecodec.lua
The crewswap.state payload: encode(writer, entries) and decode(reader). Layout: UInt16
count, then per entry UInt16 entity id, String controller name, Boolean free. See
Networking.
server/: adapters
Loaded by server/main.lua in this order: game, patch, selftest, spawn, identity,
switch, presence, roundend, death, crewdown, safety, admin. The order matters in a
few places (noted below), because modules append to shared hook lists and patch the same methods.
server/main.lua (autorun)
- Creates
CS, carrying over the client hello tables (clientBuild,clientBad,joinedAt,noHelloTold) across a server Lua reload, since every client’s Lua keeps running. CS.fail(reason): switch the mod off for the rest of the session (setslockdown, recordsfailReason, prints[CrewSwap] FAIL: <reason>, announcesFAIL_NOTICEnow and every round start).- The version gate (
checkVersions, see Safety). On a game-version mismatch (or sandboxed server Lua) it registers a singleroundStartchat notice and returns before any module loads. A different or unreadable LuaCs commit prints a[CrewSwap] WARNING: built for LuaCs ...line and boot continues. CS.recomputeActive(campaign): computesCS.campaignOkandCS.active, and sends the Ironman notice once per round.- Loads the server modules (a missing module is skipped; any other load error is a
CS.fail), printspatches installed: N/N, and schedules the in-game selftest when its marker file exists.
server/game.lua (G)
The game adapter everything else uses.
G.list(x)/G.iterate(x): converts any CLR collection (a copied table, an iterator function or a userdata enumerable such as a LINQ result) into a plain Lua array.G.accountKey(client):AccountId.StringRepresentation, or'addr:<address>'when there is no account id (matching how vanilla keys CCDs).G.clients(),G.campaign()(theMultiPlayerCampaignor nil),G.isCrew(ch),G.controllerOf(ch),G.homeOf(ch),G.homeBodyOf(client)(the live body of the client’s CCD info, searched each time so it survives respawns and mid-round joins).G.view(ch)andG.crewViews(): plain views{ id, sid, alive, conscious, removed, crew, isAI, controller, controllerName, homeOf, owner, name }for the core modules. Crew means a Team1 human whose info is a CCD info (a home) or is inCrewManager:GetCharacterInfos(true)(bots and the bench). Stable ids are only minted whileCS.active.G.tell(client, text),G.announce(text),G.isAdmin(client)(ClientPermissions.ManageSettings),G.now()(Timing.TotalTime),G.isPermadeath(),G.toggles().G.checkLiveList(): on the first campaign round, proves the real crew list converts; on failure the mod switches itself off (SELFTEST FAIL live-crew-list).
server/patch.lua (P)
P.patch(id, class, method, types, fn, when) wraps every server Hook.Patch in pcall. A patch
that does not resolve means the game is not what the build was made for, so it calls CS.fail.
P.errors[id] and P.summary() feed the selftest and the patches installed line.
server/selftest.lua
M.add(name, fn) registers an in-game check; M.run() prints SELFTEST PASS|FAIL|SKIP lines and a
summary. See Testing.
server/spawn.lua
- Prefix on
Character.Create(crewswap.forceai): setshasAi = truefor remote-player spawns with an info, in a campaign. S.isAIBody(ch): anAICharacterwith aHumanAIController.- Audits on
roundStartandcharacter.created: a player body without AI in a multiplayer campaign is aCS.fail.
server/identity.lua
Stable ids and campaign state. See Persistence.
CS.sidOfInfo(info): the sid for an info, taken from the binding, from an id just read from XML (CS.pending), or freshly minted (CS.newGuid()).CS.liveCrewInfos(campaign): bots and bench, every CCD info, and Ironman’s backup list.M.writeMirror,M.readMirror,M.bindMissing(infos, allowMint).- Patches on
CharacterInfo.Save, theCharacterInfoXML constructor,MultiPlayerCampaign.LoadandMultiPlayerCampaign.Save(prefix and postfix), plus a reload-recovery block that runs at load time.
server/switch.lua (SW)
SW.request(client, ch, source): build the requester and context,rules.validate, thenSW.switch; on denialSW.deny.SW.switch(client, target): the switch routine (see Lifecycle).SW.release(client): hand the client’s body back to the AI.SW.sendState(client),SW.broadcastState(): per-clientcrewswap.state.CS.isVoluntarySpectator(c): a lobby spectator, as opposed to one a permadeath kill set.- The
crewswap.helloandcrewswap.requestreceivers, the hello watchdog, and the “no client-side LuaCs” notice. See Networking.
server/presence.lua
client.disconnected(a prefix on vanilla’s disconnect, soclient.Characteris still set): record the client as last controller, thenSW.releaseso the body reverts to AI instead of freezing.- Postfix on
GameServer.OnInitializationCompletequeues a joining client; athinkpoll (every 0.5 s, for up toJOIN_WINDOW = 180s) places them withplacement.rejoinTargetonce they are in game and synced and the round is not ending. roundStart:placement.roundStartmoves for everyone present.
server/roundend.lua
The round-end restore, kept dead crew and per-character wallets.
CS.restoreAll(): everyone out of borrowed bodies, then everyone back into their home body.- Patches on
GameSession.EndRound,MultiPlayerCampaign.SavePlayers(prefix and postfix),MultiPlayerCampaign.End,GameSession.GetSessionCrewCharacters,CrewManager.RemoveCharacterInfo,CampaignMode.End(prefix and postfix),GameServer.EndGame,SaveUtil.SaveGameandCrewManager.InitRound. CS.onSavePlayersPre/CS.onSavePlayersPost: hook lists other modules append to; any error in them blocks the save. Becauseroundendloads beforesafety, its post-save hook (putting kept crew back) runs before the post-save integrity check.CS.reviveKept(path),R.applyRevive(info, actions),CS.snapshotWallets(),CS.commitWallets().
server/death.lua
- The kill redirect: prefix and postfix on
Character.KillProjSpecific, plus athinksafety net that gives the driver back if vanilla threw between them. - Undriven permadeath for a home the AI was running.
RespawnManager.RespawnCharactersprefix: let go of a dead borrowed corpse before respawn.RetainExperienceForNewCharacteroff (SaveExperiencePointsskipped,GetSavedExperiencePointsreturns 0).TAKEOVERBOTrouting (GameServer.ReadTakeOverBotMessage) and theClient.TryTakeOverBotpostfix.CS.onDriverDeath(client, body, disconnected).
server/crewdown.lua
Holds the round open while Free crew exists, using core/grace. A prefix on GameServer.Update
sets UsingFreeCam on every client so vanilla’s crew-down test sees “someone is up”; a postfix and
a think hook clear it again. Details in Lifecycle.
server/safety.lua
The integrity safety net. See Safety.
CS.runCheck(label),CS.noteLegit(kind, infoId),S.stores(campaign),S.holdsLobby().- Raises
SaveUtil.MaxBackupCountto 20. - Patches on
Character.KillProjSpecific,CrewManager.FireCharacter,MultiPlayerCampaign.DiscardClientCharacterData,SaveUtil.SaveGame,GameServer.EndGame,MultiPlayerCampaign.LoadCampaign(prefix and postfix) andGameServer.TryStartGame.
server/admin.lua (A)
A.load()/A.save():config.txtin<SaveFolder>/CrewSwap/, written via a.tmpfile and a move.A.onChat(text, client)on thechatMessagehook: parses withcommands.parse, runs the command, and returnstrueso the message never shows in public chat (for every sender).A.run(client, cmd):statusfor everyone;pool,protect,ownerandcheckfor admins.- Postfix on
CampaignMode.TryHireCharacter: inownpool mode the hirer owns the new hire.
client/main.lua (autorun)
Runs on clients only (if not CLIENT then return end) and keeps its state in CSC.
CSC.onClick(cm, p): prefix onCrewManager.CharacterClicked. Sends a switch request for a Free row and suppresses vanilla’s click; see Networking.CSC.onRowAdded(cm, p)(postfix onCrewManager.AddCharacterToCrewList),CSC.styleRow(c),CSC.styleAll(): greys taken rows and labels them with the controller’s name, dims crew that are not Free for this player, and restores the original colours otherwise.CSC.rebuildRow(cm, c): rebuilds a row whoseIsPlayerflag flipped since it was built (vanilla only builds objective icons for rows that were bots at creation); never mid-drag.CSC.think(): detects the client’s own control change throughGame.Client.Character, thenCSC.afterOwnControlChange(newChar)does the housekeeping singleplayer’s character selection does and multiplayer skips: close the command UI, clear inventory drag state,HintManager.OnChangeCharacter, refresh an open Talents tab, and reset or auto-hide the crew list and chat box.CSC.applyState(entries),CSC.notify(text, isError),CSC.sendHello().- Probe-installs both patches and
MakeFieldAccessible(CrewManager, 'crewList'), and looks up seven static classes (Timing,CoroutineManager,ConversationAction,HintManager,TabMenu,GameSession,ChatBox); any failure setsCSC.probesOk = false, which the hello reports to the server.