Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
- [Play Vintage Story](get-started/usage/game-client/play-vintage-story.md)
- [Edit Installations](get-started/usage/game-client/edit-installations.md)
- [Installation Backups](get-started/usage/game-client/backups.md)
- [Manage worlds](get-started/usage/game-client/worlds.md)
- [Install/Manage Mods](get-started/usage/game-client/install-manage-mods.md)
- [🌐 Translation](get-started/translation/README.md)
- [Option 1](get-started/translation/option-1.md)
Expand Down
18 changes: 18 additions & 0 deletions docs/get-started/usage/game-client/worlds.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Manage worlds

Open the worlds button beside an Installation to see the `.vcdbs` worlds in its
`Saves` folder. The list shows each world's size, last modification time, and
the number of launcher backups that exist for it.

You can back up, restore, delete, copy, or move one world at a time. Copying or
moving to another Installation adds a numeric suffix when the destination
already has a world with that name. Transfers between different Vintage Story
versions are allowed but show a warning.

World changes are disabled while either Installation is playing. The launcher
also refuses to change a world while Vintage Story has its SQLite `-wal` or
`-shm` sidecar open; close the game first so no recent save data is lost.

World backups are stored beneath the configured Backups folder and survive
deleting the live world. A restore replaces only the selected `.vcdbs` file;
other files in `Saves` are left untouched.
11 changes: 11 additions & 0 deletions src/config/configManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { normalizeModDbVisibility } from "@domain/moddbVisibility"
import { normalizeReceiveBetaUpdates } from "@domain/appUpdate/betaUpdates"
import { DEFAULT_COMPRESSION_LEVEL, DEFAULT_CONFIG_BASE } from "@domain/config/defaults"
import { normalizeServerBookmarks } from "@domain/servers/bookmarks"
import { isSafeWorldName } from "@domain/worlds/worlds"

const defaultConfig: ConfigType = {
...DEFAULT_CONFIG_BASE,
Expand All @@ -34,6 +35,7 @@ const defaultInstallation: InstallationType = {
backupsAuto: false,
compressionLevel: DEFAULT_COMPRESSION_LEVEL,
backups: [],
worldBackups: [],
lastTimePlayed: -1,
totalTimePlayed: 0,
mesaGlThread: false,
Expand Down Expand Up @@ -328,6 +330,14 @@ function normalizeBackup(value: unknown): BackupType | null {
}
}

function normalizeWorldBackup(value: unknown): WorldBackupType | null {
if (!isRecord(value)) return null
const backup = normalizeBackup(value)
const worldName = asString(value.worldName, "", 255)
if (!backup || !isSafeWorldName(worldName)) return null
return { ...backup, worldName }
}

function normalizeInstallation(value: unknown): InstallationType | null {
if (!isRecord(value)) return null
const installation: InstallationType = {
Expand All @@ -347,6 +357,7 @@ function normalizeInstallation(value: unknown): InstallationType | null {
.filter((backup): backup is BackupType => backup !== null)
.slice(0, 100)
: [],
worldBackups: Array.isArray(value.worldBackups) ? value.worldBackups.map(normalizeWorldBackup).filter((backup): backup is WorldBackupType => backup !== null) : [],
lastTimePlayed: asNumber(value.lastTimePlayed, defaultInstallation.lastTimePlayed, -1, Number.MAX_SAFE_INTEGER),
totalTimePlayed: asNumber(value.totalTimePlayed, defaultInstallation.totalTimePlayed, 0, Number.MAX_SAFE_INTEGER),
mesaGlThread: asBoolean(value.mesaGlThread, defaultInstallation.mesaGlThread),
Expand Down
18 changes: 16 additions & 2 deletions src/domain/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

/** Schema every config the launcher writes today carries. */
export const CURRENT_CONFIG_SCHEMA = 5
export const CURRENT_CONFIG_SCHEMA = 6

/**
* First schema expressed as an integer.
Expand Down Expand Up @@ -339,8 +339,22 @@ export const addGameVersionIdentity: ConfigMigration = {
}
}

/** Gives every installation its own durable world-backup record collection. */
export const addWorldBackupRecords: ConfigMigration = {
fromSchema: 5,
toSchema: 6,
migrate(doc: unknown): unknown {
if (!isRecord(doc) || !Array.isArray(doc.installations)) return { ...(doc as Record<string, unknown>) }
const installations = doc.installations.map((entry) => {
if (!isRecord(entry)) return entry
return Array.isArray(entry.worldBackups) ? entry : { ...entry, worldBackups: [] }
})
return { ...doc, installations }
}
}

/** Every migration the launcher knows, lowest schema first. */
export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [floatMarkerToIntegerSchema, stampLinkedOnExternalVersions, singleAccountToAccountList, addGameVersionIdentity]
export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [floatMarkerToIntegerSchema, stampLinkedOnExternalVersions, singleAccountToAccountList, addGameVersionIdentity, addWorldBackupRecords]

function byFromSchema(migrations: readonly ConfigMigration[]): Map<number, ConfigMigration> {
return new Map(migrations.map((migration) => [migration.fromSchema, migration]))
Expand Down
3 changes: 2 additions & 1 deletion src/domain/installations/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { deleteInstallationBackup } from "./backupDeletion"
export interface InstallationDeleteSnapshot {
path: string
backups: readonly (Pick<BackupRecord, "id" | "path"> & { isDeleting?: boolean; isRestoring?: boolean })[]
worldBackups?: readonly (Pick<BackupRecord, "id" | "path"> & { isDeleting?: boolean; isRestoring?: boolean })[]
isPlaying: boolean
isBackingUp: boolean
isRestoringBackup: boolean
Expand Down Expand Up @@ -80,7 +81,7 @@ export async function deleteInstallation(ports: DeleteInstallationPorts, input:

const failedBackupPaths: string[] = []

for (const backup of installation.backups) {
for (const backup of [...installation.backups, ...(installation.worldBackups ?? [])]) {
const result = await deleteInstallationBackup(
{ fileSystem: ports.fileSystem },
{ backup: { id: backup.id, path: backup.path, isDeleting: backup.isDeleting ?? false, isRestoring: backup.isRestoring ?? false } }
Expand Down
72 changes: 72 additions & 0 deletions src/domain/worlds/worlds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export const SAVES_FOLDER_NAME = "Saves"
export const WORLD_FILE_EXTENSION = ".vcdbs"
export const DEFAULT_WORLD_FILE_NAME = `default${WORLD_FILE_EXTENSION}`
export const MAX_WORLDS = 1_000
export const WORLD_SIDECAR_SUFFIXES = ["-wal", "-shm"] as const

export interface WorldFileEntry {
name: string
size: number
lastModified: number
isDefault: boolean
backupCount: number
}

export function isSafeWorldName(name: unknown): name is string {
if (typeof name !== "string") return false
const stem = name.slice(0, -WORLD_FILE_EXTENSION.length)
const reservedDeviceName = /^(con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\..*)?$/i
const hasControlCharacter = Array.from(name).some((character) => {
const code = character.charCodeAt(0)
return code <= 0x1f || code === 0x7f
})
return (
name.length > WORLD_FILE_EXTENSION.length &&
name.length <= 255 &&
name.toLowerCase().endsWith(WORLD_FILE_EXTENSION) &&
name !== "." &&
name !== ".." &&
!name.includes("/") &&
!name.includes("\\") &&
!name.includes("\0") &&
!hasControlCharacter &&
!/[ .]$/u.test(name) &&
!reservedDeviceName.test(stem)
)
}

export function worldSidecarNames(name: string): string[] {
return WORLD_SIDECAR_SUFFIXES.map((suffix) => `${name}${suffix}`)
}

export function hasWorldSidecars(names: readonly string[], worldName: string): boolean {
const entries = new Set(names.map((name) => name.toLocaleLowerCase("en-US")))
return worldSidecarNames(worldName).some((name) => entries.has(name.toLocaleLowerCase("en-US")))
}

export function listWorlds(entries: readonly WorldFileEntry[]): WorldFileEntry[] {
return entries
.filter((entry) => isSafeWorldName(entry.name) && Number.isFinite(entry.size) && Number.isFinite(entry.lastModified))
.sort((left, right) => right.lastModified - left.lastModified || left.name.localeCompare(right.name))
.slice(0, MAX_WORLDS)
}

export function collisionFreeWorldName(name: string, existingNames: readonly string[]): string {
const existing = new Set(existingNames.map((entry) => entry.toLocaleLowerCase("en-US")))
if (!existing.has(name.toLocaleLowerCase("en-US"))) return name
const lower = WORLD_FILE_EXTENSION.length
const stem = name.slice(0, -lower)
for (let suffix = 2; suffix <= MAX_WORLDS + 1; suffix++) {
const candidate = `${stem} (${suffix})${WORLD_FILE_EXTENSION}`
if (!existing.has(candidate.toLocaleLowerCase("en-US"))) return candidate
}
throw new Error("No available world name")
}

export function worldVersionWarning(sourceVersion: string, targetVersion: string): "different-version" | undefined {
return sourceVersion !== targetVersion ? "different-version" : undefined
}

export function canTransferWorld(sourceInstallationId: string, targetInstallationId: string): boolean {
return sourceInstallationId !== targetInstallationId
}
21 changes: 21 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,25 @@ declare global {
_restoring?: boolean
}

type WorldBackupType = BackupType & {
worldName: string
_deleting?: boolean
_restoring?: boolean
}

type WorldType = {
name: string
size: number
lastModified: number
isDefault: boolean
backupCount: number
}

type WorldListResult = { ok: true; worlds: WorldType[] } | { ok: false; reason: string }
type WorldBackupResult = { ok: true; backup: WorldBackupType } | { ok: false; reason: string }
type WorldOperationResult = { ok: true } | { ok: false; reason: string }
type WorldTransferResult = { ok: true; targetWorldName: string; warning?: "different-version" } | { ok: false; reason: string }

/**
* One server an Installation can join straight from the launcher (#460).
*
Expand Down Expand Up @@ -246,6 +265,7 @@ declare global {
backupsAuto: boolean
compressionLevel: number
backups: BackupType[]
worldBackups?: WorldBackupType[]
lastTimePlayed: number
totalTimePlayed: number
mesaGlThread: boolean
Expand All @@ -263,6 +283,7 @@ declare global {
_backuping?: boolean
_restoringBackup?: boolean
_updatingMods?: boolean
_worldsCount?: number
}

type ConfigType = BasicConfigType & {
Expand Down
24 changes: 24 additions & 0 deletions src/ipc/archiveValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as tar from "tar"

import type { ArchiveSizeLimits } from "./validation"
import { archiveSizeLimits, isArchiveSymlink, isSafeArchiveEntry, isSafeTarEntryType, isTarGzName } from "./validation"
import { isSafeWorldName } from "@domain/worlds/worlds"

const MAX_ARCHIVE_ENTRIES = 100_000

Expand Down Expand Up @@ -155,3 +156,26 @@ export async function validateArchive(filePath: string, limits: ArchiveSizeLimit
if (filePath.toLowerCase().endsWith(".zip")) return validateZipArchive(filePath, limits)
throw new Error("Archive format is not supported")
}

/** Validates a launcher-created world archive before any entry is extracted. */
export async function validateWorldBackupArchive(filePath: string, worldName: string): Promise<void> {
if (!isTarGzName(filePath) || !isSafeWorldName(worldName)) throw new Error("World backup format is not supported")

let count = 0
let valid = false
try {
await tar.list({
file: filePath,
onReadEntry: (entry) => {
count++
const size = Number(entry.size)
if (count === 1 && entry.type === "File" && entry.path === worldName && isSafeArchiveEntry(entry.path) && Number.isFinite(size) && size >= 0 && size <= archiveSizeLimits(true).entryBytes)
valid = true
}
})
} catch {
throw new Error("World backup could not be read")
}

if (count !== 1 || !valid) throw new Error("World backup must contain exactly one world file")
}
9 changes: 8 additions & 1 deletion src/ipc/handlers/configHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ ipcMain.handle(IPC_CHANNELS.CONFIG_MANAGER.GET_CONFIG, async (event): Promise<Co
ipcMain.handle(IPC_CHANNELS.CONFIG_MANAGER.SAVE_CONFIG, async (event, config: ConfigType): Promise<SaveConfigResult> => {
assertTrustedIpcSender(event)
if (!isRecord(config)) return invalidPayloadResult()
const normalizedConfig = normalizeConfig(config)
const currentConfig = await getConfig()
const requestedConfig = normalizeConfig(config)
const normalizedConfig = normalizeConfig({
...requestedConfig,
installations: requestedConfig.installations.map((installation) => ({
...installation,
worldBackups: currentConfig.installations.find((current) => current.id === installation.id)?.worldBackups ?? installation.worldBackups
}))
})
if (!(await assertConfigPathsAuthorized(normalizedConfig, currentConfig))) return unauthorizedPathResult()
return saveOutcomeToResult(await saveConfig(normalizedConfig))
})
33 changes: 21 additions & 12 deletions src/ipc/handlers/gameHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { createProcessSampler } from "@src/ipc/adapters/processSampler"
import { createPlaySessionRecorder, forgetPlaySessions, readPlaySessions, recordPlaySession } from "@src/ipc/playSessionsStore"
import { getAccountSecrets, saveAccountSecrets } from "@src/ipc/accountStore"
import { getConfig } from "@src/config/configManager"
import { clearInstallationPlaying, markInstallationPlaying } from "@src/ipc/installationActivity"
import { detectInstalledGameVersion } from "@domain/versions/detect"
import { buildSessionReport, type InstalledModRef } from "@domain/gameLogs/report"
import { scanInstalledMods } from "@domain/mods/scanInstalled"
Expand Down Expand Up @@ -270,6 +271,7 @@ ipcMain.handle(IPC_CHANNELS.GAME_MANAGER.EXECUTE_GAME, async (event, version: un
safeVersion.path = await assertManagedPath(safeVersion.path, "game version path")
safeInstallation.path = await assertManagedPath(safeInstallation.path, "installation path")
const config = await getConfig()
const installationId = config.installations.find((candidate) => comparablePath(candidate.path) === comparablePath(safeInstallation.path))?.id
const account = config.accounts.find((candidate) => candidate.playerUid === config.activeAccountId) ?? null
const accountSecrets = account ? await getAccountSecrets(account.playerUid) : null
logMessage("info", `[back] [ipc] [ipc/handlers/gameHandlers.ts] [EXECUTE_GAME] Trying to run Vintage Story ${safeVersion.version}.`)
Expand Down Expand Up @@ -439,24 +441,31 @@ ipcMain.handle(IPC_CHANNELS.GAME_MANAGER.EXECUTE_GAME, async (event, version: un

// The id comes from the config the launcher wrote, never from the renderer's own object, so the
// file name a session lands under cannot be chosen by whatever sent the launch.
const installationId = config.installations.find((candidate) => comparablePath(candidate.path) === comparablePath(safeInstallation.path))?.id
const markedPlaying = installationId ? markInstallationPlaying(installationId) : false
if (installationId && !markedPlaying) return invalidRequestResult()
// Windows has no /proc, so its sampler reads `tasklist` and needs a probe to run it with. Passing
// it only there keeps macOS on the absent sampler, which is what the factory answers with none.
const platform = os.platform()
const samplerOptions = platform === "win32" ? { processProbe: tasklistProbe() } : {}
const recorder = config.measurePlaySessions && installationId ? createPlaySessionRecorder(createProcessSampler(platform, samplerOptions)) : undefined

const outcome = await realGameProcess().run({
command: plan.command,
args: plan.args,
env: { ...process.env, ...processEnv, ...plan.env },
cwd: plan.cwd,
...(recorder ? { onStarted: recorder.onStarted } : {})
})

// Settles the sampling loop on the same path the launch outcome settles on, whichever way it
// went, so the timer cannot outlive this handler.
const session = await recorder?.finish()
let outcome: GameProcessOutcome
let session: PlaySession | undefined
try {
outcome = await realGameProcess().run({
command: plan.command,
args: plan.args,
env: { ...process.env, ...processEnv, ...plan.env },
cwd: plan.cwd,
...(recorder ? { onStarted: recorder.onStarted } : {})
})

// Settles the sampling loop on the same path the launch outcome settles on, whichever way it
// went, so the timer cannot outlive this handler.
session = await recorder?.finish()
} finally {
if (installationId) clearInstallationPlaying(installationId)
}
if (session && installationId) {
const stored = await recordPlaySession(installationId, session)
const shape = session.partial ? "partial" : "complete"
Expand Down
Loading
Loading