@gamecrate/cli 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/gamecrate.js +246 -246
  2. package/dist/lib.js +296 -0
  3. package/dist/types/cli/args.d.ts +40 -0
  4. package/dist/types/cli/help.d.ts +7 -0
  5. package/dist/types/cli/output.d.ts +56 -0
  6. package/dist/types/config/builtin.d.ts +3 -0
  7. package/dist/types/config/jsonc.d.ts +5 -0
  8. package/dist/types/config/load.d.ts +45 -0
  9. package/dist/types/config/validate.d.ts +12 -0
  10. package/dist/types/docker/identity.d.ts +6 -0
  11. package/dist/types/docker/preflight.d.ts +3 -0
  12. package/dist/types/docker/run.d.ts +37 -0
  13. package/dist/types/docker/spec.d.ts +24 -0
  14. package/dist/types/docker/window.d.ts +21 -0
  15. package/dist/types/index.d.ts +2 -0
  16. package/dist/types/launch/generate.d.ts +8 -0
  17. package/dist/types/launch/instance.d.ts +20 -0
  18. package/dist/types/launch/prepare.d.ts +44 -0
  19. package/dist/types/launch/resolve.d.ts +17 -0
  20. package/dist/types/launch/stage.d.ts +13 -0
  21. package/dist/types/lib.d.ts +3 -0
  22. package/dist/types/mods/modindex.d.ts +29 -0
  23. package/dist/types/mods/staleness.d.ts +28 -0
  24. package/dist/types/mods/worktree.d.ts +18 -0
  25. package/dist/types/plugin.d.ts +35 -0
  26. package/dist/types/types.d.ts +394 -0
  27. package/package.json +15 -10
  28. package/src/cli/args.ts +0 -592
  29. package/src/cli/help.ts +0 -193
  30. package/src/cli/output.ts +0 -246
  31. package/src/config/builtin.ts +0 -19
  32. package/src/config/jsonc.ts +0 -21
  33. package/src/config/load.ts +0 -387
  34. package/src/config/validate.ts +0 -0
  35. package/src/docker/identity.ts +0 -25
  36. package/src/docker/preflight.ts +0 -243
  37. package/src/docker/run.ts +0 -212
  38. package/src/docker/spec.ts +0 -357
  39. package/src/docker/window.ts +0 -152
  40. package/src/index.ts +0 -875
  41. package/src/launch/generate.ts +0 -151
  42. package/src/launch/instance.ts +0 -106
  43. package/src/launch/prepare.ts +0 -332
  44. package/src/launch/resolve.ts +0 -383
  45. package/src/launch/stage.ts +0 -97
  46. package/src/lib.ts +0 -22
  47. package/src/mods/modindex.ts +0 -539
  48. package/src/mods/staleness.ts +0 -125
  49. package/src/mods/worktree.ts +0 -107
  50. package/src/plugin.ts +0 -152
  51. package/src/types.ts +0 -423
package/src/docker/run.ts DELETED
@@ -1,212 +0,0 @@
1
- import { spawn } from 'node:child_process'
2
- import type { ChildProcess, StdioOptions } from 'node:child_process'
3
- import { createWriteStream, mkdirSync } from 'node:fs'
4
- import { open, readdir, stat } from 'node:fs/promises'
5
- import { join } from 'node:path'
6
- import type { Readable, Writable } from 'node:stream'
7
- import { setTimeout as sleep } from 'node:timers/promises'
8
- import { TextDecoder } from 'node:util'
9
- import type { DockerRunSpec } from '../types'
10
- import { Exit } from '../types'
11
- import { toDockerArgs } from './spec'
12
-
13
- /** argv as one array, the way every caller here has it. */
14
- export function spawnArgv(argv: string[], stdio: StdioOptions): ChildProcess {
15
- return spawn(argv[0]!, argv.slice(1), { stdio })
16
- }
17
-
18
- /** Rejects when the spawn itself fails, so a missing binary lands where a bad exit code would. */
19
- export function exited(proc: ChildProcess): Promise<number> {
20
- return new Promise((resolve, reject) => {
21
- proc.once('error', reject)
22
- proc.once('close', (code) => resolve(code ?? 1))
23
- })
24
- }
25
-
26
- export async function collect(stream: Readable): Promise<string> {
27
- const chunks: Buffer[] = []
28
- for await (const chunk of stream) chunks.push(chunk as Buffer)
29
- return Buffer.concat(chunks).toString('utf8')
30
- }
31
-
32
- /**
33
- * Runs a short command and collects both streams. A missing binary is exit 127 with the spawn
34
- * error as stderr, so every caller can report it the same way rather than throwing.
35
- */
36
- export async function capture(argv: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
37
- try {
38
- const proc = spawnArgv(argv, ['ignore', 'pipe', 'pipe'])
39
- const [stdout, stderr, code] = await Promise.all([
40
- collect(proc.stdout!),
41
- collect(proc.stderr!),
42
- exited(proc),
43
- ])
44
- return { code, stdout, stderr }
45
- } catch (error) {
46
- return { code: 127, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
47
- }
48
- }
49
-
50
- /** The tee'd combined stream, and what waitForMarker watches. */
51
- export const STDOUT_LOG = 'stdout.log'
52
-
53
- const MARKER_POLL_MS = 200
54
-
55
- export interface RunOptions {
56
- /** Run log directory; created if missing. Receives stdout.log. */
57
- logDir: string
58
- /** Passed to `docker stop --timeout` when a signal arrives. */
59
- stopTimeoutSeconds?: number
60
- }
61
-
62
- /**
63
- * Spawns docker directly rather than through a pipeline, so the game's status is the status.
64
- * `exec docker run | tee` returns tee's code, which is why the old scripts always reported 0.
65
- */
66
- export async function runContainer(spec: DockerRunSpec, opts: RunOptions): Promise<number> {
67
- const stopTimeout = opts.stopTimeoutSeconds ?? 10
68
-
69
- mkdirSync(opts.logDir, { recursive: true })
70
- const sink = createWriteStream(join(opts.logDir, STDOUT_LOG))
71
-
72
- const proc = spawnArgv(['docker', ...toDockerArgs(spec)], ['inherit', 'pipe', 'pipe'])
73
-
74
- let interrupted = false
75
- const onSignal = () => {
76
- if (interrupted) return
77
- interrupted = true
78
- void stopContainer(spec.name, stopTimeout)
79
- }
80
- process.on('SIGINT', onSignal)
81
- process.on('SIGTERM', onSignal)
82
-
83
- // Registered before the tees: an unhandled "error" event would take the process down, and
84
- // draining the pipes first is what keeps a chatty container from filling them and stalling.
85
- const code = exited(proc)
86
- try {
87
- await Promise.all([
88
- tee(proc.stdout!, sink, process.stdout),
89
- tee(proc.stderr!, sink, process.stderr),
90
- ])
91
- const status = await code
92
- return interrupted ? Exit.Interrupted : status
93
- } finally {
94
- process.off('SIGINT', onSignal)
95
- process.off('SIGTERM', onSignal)
96
- await new Promise<void>((resolve) => sink.end(resolve))
97
- }
98
- }
99
-
100
- export async function stopContainer(name: string, timeoutSeconds: number): Promise<void> {
101
- const proc = spawnArgv(['docker', 'stop', '--timeout', String(timeoutSeconds), name], 'ignore')
102
- await exited(proc).catch(() => {})
103
- }
104
-
105
- /**
106
- * Host-side marker watch. Watches container stdout AND the game's own log file: RimWorld
107
- * sends Verse.Log output to -logfile, never to stdout, so a stdout-only watch can never
108
- * match a RimWorld mod's message.
109
- */
110
- export async function waitForMarker(
111
- sources: string[],
112
- marker: string,
113
- timeoutSeconds: number,
114
- ): Promise<boolean> {
115
- const deadline = Date.now() + timeoutSeconds * 1000
116
- const carry = Math.max(marker.length - 1, 0)
117
- const seen = new Map<string, Watched>()
118
- const startedAt = Date.now()
119
-
120
- while (true) {
121
- for (const path of await expandSources(sources)) {
122
- let state = seen.get(path)
123
- if (state === undefined) {
124
- // Logs/Player-prev.log holds the PREVIOUS run's marker verbatim, so its history
125
- // would match instantly. Skip what a file already held before this watch began,
126
- // keyed on mtime so a fresh stdout.log is still read from byte zero.
127
- state = { offset: await staleSize(path, startedAt), tail: '', decoder: new TextDecoder() }
128
- seen.set(path, state)
129
- }
130
-
131
- if (await scan(path, state, marker, carry)) return true
132
- }
133
-
134
- if (Date.now() >= deadline) return false
135
- await sleep(Math.min(MARKER_POLL_MS, Math.max(deadline - Date.now(), 0)))
136
- }
137
- }
138
-
139
- interface Watched {
140
- offset: number
141
- tail: string
142
- /** Per path: a shared streaming decoder corrupts every file after the first. */
143
- decoder: TextDecoder
144
- }
145
-
146
- /** Bytes to skip: a file last written before the watch started is a previous run's log. */
147
- async function staleSize(path: string, startedAt: number): Promise<number> {
148
- return stat(path).then(
149
- (info) => (info.mtimeMs < startedAt ? info.size : 0),
150
- () => 0,
151
- )
152
- }
153
-
154
- /** Never throws: one unreadable file must not reject the whole watch. */
155
- async function scan(
156
- path: string,
157
- state: Watched,
158
- marker: string,
159
- carry: number,
160
- ): Promise<boolean> {
161
- const handle = await open(path, 'r').catch(() => null)
162
- if (handle === null) return false
163
- try {
164
- const { size } = await handle.stat()
165
- if (size < state.offset) {
166
- state.offset = 0
167
- state.tail = ''
168
- state.decoder = new TextDecoder()
169
- }
170
- if (size <= state.offset) return false
171
-
172
- const buffer = Buffer.alloc(size - state.offset)
173
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, state.offset)
174
- state.offset += bytesRead
175
- const text = state.tail + state.decoder.decode(buffer.subarray(0, bytesRead), { stream: true })
176
- if (text.includes(marker)) return true
177
- state.tail = carry > 0 ? text.slice(-carry) : ''
178
- return false
179
- } catch {
180
- return false
181
- } finally {
182
- await handle.close().catch(() => {})
183
- }
184
- }
185
-
186
- /** A source is a file or a directory of logs; directories are rescanned every poll. */
187
- async function expandSources(sources: string[]): Promise<string[]> {
188
- const out: string[] = []
189
- for (const source of sources) {
190
- const info = await stat(source).catch(() => null)
191
- if (info === null) {
192
- out.push(source)
193
- continue
194
- }
195
- if (!info.isDirectory()) {
196
- out.push(source)
197
- continue
198
- }
199
- const entries = await readdir(source).catch((): string[] => [])
200
- for (const entry of entries) {
201
- if (entry.toLowerCase().endsWith('.log')) out.push(join(source, entry))
202
- }
203
- }
204
- return out
205
- }
206
-
207
- async function tee(stream: Readable, sink: Writable, mirror: NodeJS.WriteStream): Promise<void> {
208
- for await (const chunk of stream) {
209
- mirror.write(chunk as Buffer)
210
- sink.write(chunk as Buffer)
211
- }
212
- }
@@ -1,357 +0,0 @@
1
- import { existsSync, realpathSync } from 'node:fs'
2
- import { homedir, hostname } from 'node:os'
3
- import { basename, join } from 'node:path'
4
- import type {
5
- DataDirSpec,
6
- DockerRunSpec,
7
- Identity,
8
- LaunchPlan,
9
- Mount,
10
- } from '../types'
11
- import { GamecrateError, Exit } from '../types'
12
-
13
- /** Container-side XDG_RUNTIME_DIR. A sized tmpfs; display and audio sockets land inside it. */
14
- export const CONTAINER_RUNTIME_DIR = '/tmp/xdg'
15
-
16
- /** Where the run directory is bound, so `-logfile /logs/Player.log` lands beside stdout.log. */
17
- export const CONTAINER_LOG_DIR = '/logs'
18
-
19
- /** X11's well-known socket directory. The path is the same on both sides or DISPLAY lies. */
20
- const X11_SOCKET_DIR = '/tmp/.X11-unix'
21
-
22
- /** Outside XDG_RUNTIME_DIR on purpose: that is a tmpfs, and a bind under it races the tmpfs. */
23
- const CONTAINER_XAUTHORITY = '/tmp/xauth'
24
-
25
- /** Persistent XDG root. Outside HOME because HOME is a tmpfs and a bind under it races. */
26
- const CONTAINER_XDG_DIR = '/xdg'
27
-
28
- const RUNTIME_DIR_SIZE = '64m'
29
- const HOME_SIZE = '64m'
30
- const MASK_SIZE = '1m'
31
-
32
- export function buildRunSpec(
33
- plan: LaunchPlan,
34
- modMounts: Mount[],
35
- identity: Identity,
36
- ): DockerRunSpec {
37
- const { gameConfig: game, settings } = plan
38
- const headed = plan.mode === 'headed'
39
-
40
- const mounts: Mount[] = []
41
- const env: Record<string, string> = {
42
- HOME: identity.home,
43
- USER: identity.user,
44
- LOGNAME: identity.user,
45
- }
46
-
47
- if (game.gameFiles.source === 'mount') {
48
- if (!game.gameFiles.host) {
49
- throw new GamecrateError(
50
- `gameFiles.source is "mount" but no host path is set for ${plan.game}`,
51
- Exit.Config,
52
- )
53
- }
54
- mounts.push({ type: 'bind', source: hostPath(game.gameFiles.host), target: game.gameFiles.container, readonly: true })
55
- }
56
-
57
- mounts.push({ type: 'bind', source: hostPath(plan.stageDirHost), target: game.modsDir.container, readonly: true })
58
- // Nested per-mod binds sit inside the staged tree; the game never writes to a mod source.
59
- for (const mount of modMounts) {
60
- mounts.push(mount.type === 'bind' ? { ...mount, readonly: true } : mount)
61
- }
62
-
63
- // Read-write on purpose: both engines Create() subdirectories at boot and a ro mount fails there.
64
- mounts.push({ type: 'bind', source: hostPath(plan.dataDirHost), target: game.dataDir.container })
65
-
66
- // xvfb-run -a picks a free display itself, which removes both the hardcoded :99 and the
67
- // startup race the old launch.sh papered over with `sleep 2`.
68
- const command = headed
69
- ? [game.executable]
70
- : [
71
- 'xvfb-run',
72
- '-a',
73
- `--server-args=-screen 0 ${settings.width}x${settings.height}x24`,
74
- game.executable,
75
- ]
76
-
77
- if (game.dataDir.mode === 'arg') {
78
- command.push(validateDataDirArg(game.dataDir))
79
- } else {
80
- Object.assign(env, game.dataDir.env)
81
- }
82
-
83
- if (game.logFile.mode === 'arg') {
84
- mounts.push({ type: 'bind', source: hostPath(plan.runDirHost), target: CONTAINER_LOG_DIR })
85
- command.push(game.logFile.arg, `${CONTAINER_LOG_DIR}/Player.log`)
86
- }
87
-
88
- // Unconditional, independent of the uid mode: a game may read mods from both roots.
89
- for (const target of game.modsDir.mask ?? []) {
90
- mounts.push({ type: 'tmpfs', target, size: MASK_SIZE, uid: identity.uid, gid: identity.gid, mode: '755' })
91
- }
92
-
93
- if (identity.uid !== 0) {
94
- mounts.push({ type: 'tmpfs', target: identity.home, size: HOME_SIZE, uid: identity.uid, gid: identity.gid, mode: '700' })
95
- }
96
-
97
- mounts.push({
98
- type: 'tmpfs',
99
- target: CONTAINER_RUNTIME_DIR,
100
- size: RUNTIME_DIR_SIZE,
101
- uid: identity.uid,
102
- gid: identity.gid,
103
- mode: '700',
104
- })
105
- env.XDG_RUNTIME_DIR = CONTAINER_RUNTIME_DIR
106
-
107
- // HOME is a tmpfs, so $HOME/.config and $HOME/.local/share are empty every run and .NET's
108
- // GetFolderPath hands back "" for a missing directory. Point XDG at a per-profile bind
109
- // instead. A game that already sets XDG_DATA_HOME to its save dir never gets
110
- // overwritten.
111
- mounts.push({ type: 'bind', source: hostPath(plan.configDirHost), target: CONTAINER_XDG_DIR })
112
- env.XDG_CONFIG_HOME = `${CONTAINER_XDG_DIR}/config`
113
- env.XDG_CACHE_HOME = `${CONTAINER_XDG_DIR}/cache`
114
- env.XDG_DATA_HOME ??= `${CONTAINER_XDG_DIR}/data`
115
-
116
- if (headed) {
117
- if (settings.display === 'x11') {
118
- const x11 = x11Session()
119
- if (x11) {
120
- mounts.push({ type: 'bind', source: X11_SOCKET_DIR, target: X11_SOCKET_DIR })
121
- env.DISPLAY = x11.display
122
- env.XDG_SESSION_TYPE = 'x11'
123
- env.SDL_VIDEODRIVER = 'x11'
124
- env.QT_QPA_PLATFORM = 'xcb'
125
- if (x11.xauthority) {
126
- mounts.push({ type: 'bind', source: x11.xauthority, target: CONTAINER_XAUTHORITY, readonly: true })
127
- env.XAUTHORITY = CONTAINER_XAUTHORITY
128
- }
129
- }
130
- } else {
131
- const wayland = waylandSocket()
132
- if (wayland) {
133
- const target = `${CONTAINER_RUNTIME_DIR}/${wayland.name}`
134
- mounts.push({ type: 'bind', source: wayland.source, target })
135
- env.WAYLAND_DISPLAY = wayland.name
136
- env.XDG_SESSION_TYPE = 'wayland'
137
- env.SDL_VIDEODRIVER = 'wayland'
138
- env.QT_QPA_PLATFORM = 'wayland'
139
- }
140
- }
141
- if (settings.audio) {
142
- for (const socket of audioSockets()) {
143
- mounts.push({ type: 'bind', source: socket.source, target: `${CONTAINER_RUNTIME_DIR}/${socket.name}` })
144
- }
145
- env.PULSE_SERVER = `unix:${CONTAINER_RUNTIME_DIR}/pulse/native`
146
- }
147
- }
148
- // Offscreen modes get their X server from xvfb-run below; DISPLAY is set by it, not by us.
149
-
150
- const deviceCgroupRules: string[] = []
151
- if (settings.input) {
152
- mounts.push({ type: 'bind', source: '/dev/input', target: '/dev/input', readonly: true })
153
- deviceCgroupRules.push('c 13:* rmw')
154
- }
155
-
156
- const devices: string[] = []
157
- if (settings.gpu) devices.push('nvidia.com/gpu=all')
158
- Object.assign(env, glEnv(settings.gpu))
159
-
160
- command.push(...(settings.gameArgs ?? []))
161
-
162
- return {
163
- image: game.image.ref,
164
- name: containerName(plan),
165
- labels: {
166
- 'gamecrate.game': plan.game,
167
- 'gamecrate.profile': plan.profile,
168
- ...(plan.instance === undefined ? {} : { 'gamecrate.instance': plan.instance }),
169
- },
170
- identity,
171
- env,
172
- mounts,
173
- devices,
174
- deviceCgroupRules,
175
- network: settings.network,
176
- memory: settings.memory,
177
- memorySwap: settings.memory,
178
- cpus: settings.cpus,
179
- pidsLimit: settings.pidsLimit,
180
- ulimits: ['core=0'],
181
- workdir: game.gameFiles.container,
182
- // An X client whose WM_CLIENT_MACHINE is foreign gets ` <@name>` stapled to its caption.
183
- ...(headed && settings.display === 'x11' ? { hostname: hostname() } : {}),
184
- command,
185
- extraArgs: [...(settings.dockerArgs ?? [])],
186
- }
187
- }
188
-
189
- /** Instances of one profile run side by side, so the name has to carry which one this is. */
190
- export function containerName(plan: LaunchPlan): string {
191
- const base = `gamecrate-${plan.game}-${plan.profile}`
192
- return plan.instance === undefined ? base : `${base}-${plan.instance}`
193
- }
194
-
195
- /** What the window is renamed to, so a taskbar full of worktrees is readable. */
196
- export function windowTitle(plan: LaunchPlan): string {
197
- const base = `${plan.game} ${plan.profile}`
198
- return plan.instance === undefined ? base : `${base} / ${plan.instance}`
199
- }
200
-
201
- export function toDockerArgs(spec: DockerRunSpec): string[] {
202
- const args = ['run', '--rm', '--init', '--name', spec.name]
203
- if (spec.hostname !== undefined) args.push('--hostname', spec.hostname)
204
-
205
- for (const [key, value] of Object.entries(spec.labels)) args.push('--label', `${key}=${value}`)
206
- args.push('--user', `${spec.identity.uid}:${spec.identity.gid}`)
207
- for (const [key, value] of Object.entries(spec.env)) args.push('--env', `${key}=${value}`)
208
- for (const mount of spec.mounts) args.push(...mountArgs(mount))
209
- for (const device of spec.devices) args.push('--device', device)
210
- for (const rule of spec.deviceCgroupRules) args.push('--device-cgroup-rule', rule)
211
- for (const ulimit of spec.ulimits) args.push('--ulimit', ulimit)
212
-
213
- args.push('--network', spec.network)
214
- args.push('--memory', spec.memory)
215
- args.push('--memory-swap', spec.memorySwap)
216
- args.push('--cpus', String(spec.cpus))
217
- args.push('--pids-limit', String(spec.pidsLimit))
218
- args.push('--workdir', spec.workdir)
219
- args.push(...spec.extraArgs)
220
- // Acquisition is an earlier explicit step; the run must never fetch a different digest.
221
- args.push('--pull=never')
222
- // The image's own ENTRYPOINT is not ours to trust: RimWorld's is ["/bin/bash"], which
223
- // would run the game's ELF as a shell script. State it explicitly every time.
224
- const [entrypoint, ...rest] = spec.command
225
- if (entrypoint !== undefined) args.push('--entrypoint', entrypoint)
226
- args.push(spec.image, ...rest)
227
-
228
- return args
229
- }
230
-
231
- /**
232
- * `--mount` for binds so a missing source errors instead of being created root-owned;
233
- * `--tmpfs` for tmpfs because `--mount type=tmpfs` has no uid=/gid= options.
234
- */
235
- function mountArgs(mount: Mount): string[] {
236
- if (mount.type === 'tmpfs') {
237
- const opts = ['rw']
238
- if (mount.uid !== undefined) opts.push(`uid=${mount.uid}`)
239
- if (mount.gid !== undefined) opts.push(`gid=${mount.gid}`)
240
- if (mount.mode) opts.push(`mode=${mount.mode}`)
241
- opts.push(`size=${mount.size ?? RUNTIME_DIR_SIZE}`)
242
- return ['--tmpfs', `${mount.target}:${opts.join(',')}`]
243
- }
244
-
245
- if (!mount.source) {
246
- throw new GamecrateError(`bind mount at ${mount.target} has no source`, Exit.Config)
247
- }
248
- const fields = [`type=bind`, `src=${mount.source}`, `dst=${mount.target}`]
249
- if (mount.readonly) fields.push('readonly')
250
- return ['--mount', fields.map(csvField).join(',')]
251
- }
252
-
253
- /** Docker parses the option string as CSV, so a comma in a path has to be quoted. */
254
- function csvField(field: string): string {
255
- if (!field.includes(',') && !field.includes('"')) return field
256
- return `"${field.replaceAll('"', '""')}"`
257
- }
258
-
259
- /**
260
- * RimWorld's TryGetCommandLineArg splits argv on `=` and requires exactly two parts. A path
261
- * with `=` falls back to an ephemeral in-container path and `--rm` takes the save with it (L10).
262
- */
263
- function validateDataDirArg(dataDir: Extract<DataDirSpec, { mode: 'arg' }>): string {
264
- if (dataDir.container.includes('=')) {
265
- throw new GamecrateError(
266
- `container data path contains "=": ${dataDir.container}`,
267
- Exit.Config,
268
- 'RimWorld silently ignores -savedatafolder when the argv element does not split into exactly two parts, and the save is lost with --rm.',
269
- )
270
- }
271
-
272
- const parts = dataDir.arg.split('=')
273
- if (parts.length !== 2) {
274
- throw new GamecrateError(
275
- `dataDir.arg must contain exactly one "=": ${dataDir.arg}`,
276
- Exit.Config,
277
- )
278
- }
279
-
280
- if (trimSlash(parts[1] ?? '') !== trimSlash(dataDir.container)) {
281
- throw new GamecrateError(
282
- `dataDir.arg points at ${parts[1]} but the mount target is ${dataDir.container}`,
283
- Exit.Config,
284
- 'The engine would write to a path that is not the mounted data directory.',
285
- )
286
- }
287
-
288
- return dataDir.arg
289
- }
290
-
291
- function trimSlash(path: string): string {
292
- return path.length > 1 ? path.replace(/\/+$/, '') : path
293
- }
294
-
295
- /** The image bakes llvmpipe, so the tool states the whole GL story rather than inheriting it. */
296
- function glEnv(gpu: boolean): Record<string, string> {
297
- if (!gpu) return { LIBGL_ALWAYS_SOFTWARE: '1', GALLIUM_DRIVER: 'llvmpipe' }
298
-
299
- const env: Record<string, string> = { LIBGL_ALWAYS_SOFTWARE: '0', GALLIUM_DRIVER: '' }
300
- if (hasNvidia()) {
301
- env.__GLX_VENDOR_LIBRARY_NAME = 'nvidia'
302
- env.__NV_PRIME_RENDER_OFFLOAD = '1'
303
- }
304
- return env
305
- }
306
-
307
- /** Gated on the detected vendor, not on the game: this class of host also carries radeon_icd. */
308
- function hasNvidia(): boolean {
309
- return (
310
- existsSync('/dev/nvidiactl') ||
311
- existsSync('/etc/cdi/nvidia.yaml') ||
312
- existsSync('/usr/share/vulkan/icd.d/nvidia_icd.json')
313
- )
314
- }
315
-
316
- /**
317
- * The host X session a headed run joins. The cookie is looked up separately from the socket
318
- * because XWayland under a display manager keeps it in XDG_RUNTIME_DIR, not ~/.Xauthority.
319
- * Whether the socket directory is really there is checkBindSources' job, as with every bind.
320
- */
321
- export function x11Session(): { display: string; xauthority: string | null } | null {
322
- const display = process.env.DISPLAY
323
- if (!display) return null
324
-
325
- const cookie = process.env.XAUTHORITY ?? join(homedir(), '.Xauthority')
326
- return { display, xauthority: existsSync(cookie) ? cookie : null }
327
- }
328
-
329
- export function waylandSocket(): { source: string; name: string } | null {
330
- const display = process.env.WAYLAND_DISPLAY
331
- const runtime = process.env.XDG_RUNTIME_DIR
332
- if (!display) return null
333
-
334
- const source = display.startsWith('/') ? display : runtime ? join(runtime, display) : null
335
- if (!source || !existsSync(source)) return null
336
- return { source, name: basename(source) }
337
- }
338
-
339
- function audioSockets(): { source: string; name: string }[] {
340
- const runtime = process.env.XDG_RUNTIME_DIR
341
- if (!runtime) return []
342
-
343
- const found: { source: string; name: string }[] = []
344
- for (const name of ['pipewire-0', 'pulse/native']) {
345
- const source = join(runtime, name)
346
- if (existsSync(source)) found.push({ source, name })
347
- }
348
- return found
349
- }
350
-
351
- function hostPath(path: string): string {
352
- try {
353
- return realpathSync(path)
354
- } catch {
355
- return path
356
- }
357
- }