This commit is contained in:
2026-07-21 22:06:10 +00:00
parent afc64b83c1
commit 5215560ede
25 changed files with 4364 additions and 333 deletions
View File
+2 -1
View File
@@ -23,7 +23,8 @@ Thumbs.db
apps/daemon/target/
# Database
packages/database/drizzle/
packages/database/drizzle/*
!packages/database/drizzle/0007_satisfactory_game.sql
# Common JS/TS
coverage/
+3 -1
View File
@@ -157,8 +157,10 @@ source-gamepanel/
| Minecraft: Bedrock Edition | `itzg/minecraft-bedrock-server` | 19132 | `.properties` | — |
| Terraria | `ryshe/terraria` | 7777 | keyvalue | — |
| Rust | `didstopia/rust-server` | 28015 | — | — |
| Satisfactory | `wolveix/satisfactory-server` | 7777 + 8888/tcp | — | — |
| FiveM | `spritsail/fivem:stable` | 30120 | `server.cfg` (keyvalue-style) | — |
Adding new games requires only a database seed entry — no code changes needed.
Many games can be added with a database seed entry alone. Some images still need small daemon-side tweaks for mount paths, port protocols, or config parsing.
---
+2 -2
View File
@@ -187,7 +187,7 @@ function parseKeyValue(content: string): ConfigEntry[] {
const entries: ConfigEntry[] = [];
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) continue;
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) continue;
// Match: key "value" or key value
const match = trimmed.match(/^(\S+)\s+"([^"]*)"/) || trimmed.match(/^(\S+)\s+(.*)/);
@@ -210,7 +210,7 @@ function serializeKeyValue(entries: ConfigEntry[], originalContent?: string): st
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('//')) {
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
result.push(line);
continue;
}
+61 -26
View File
@@ -200,6 +200,11 @@ interface DaemonServiceClient extends grpc.Client {
metadata: grpc.Metadata,
callback: UnaryCallback<DaemonManagedDatabaseCredentialsRaw>,
): void;
importDatabaseSql(
request: { database_name: string; sql: string },
metadata: grpc.Metadata,
callback: UnaryCallback<EmptyResponse>,
): void;
updateDatabasePassword(
request: { username: string; password: string },
metadata: grpc.Metadata,
@@ -319,9 +324,7 @@ function buildGrpcTarget(fqdn: string, grpcPort: number): string {
const withoutPath = host.replace(/\/.*$/, '');
if (/^\[.+\](?::\d+)?$/.test(withoutPath)) {
const innerHost = withoutPath
.replace(/^\[/, '')
.replace(/\](?::\d+)?$/, '');
const innerHost = withoutPath.replace(/^\[/, '').replace(/\](?::\d+)?$/, '');
return `[${innerHost}]:${grpcPort}`;
}
if (/^[^:]+:\d+$/.test(withoutPath)) {
@@ -340,14 +343,10 @@ function getMetadata(daemonToken: string): grpc.Metadata {
function createClient(node: DaemonNodeConnection): DaemonServiceClient {
const target = buildGrpcTarget(node.fqdn, node.grpcPort);
return new DaemonService(
target,
grpc.credentials.createInsecure(),
{
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
},
) as unknown as DaemonServiceClient;
return new DaemonService(target, grpc.credentials.createInsecure(), {
'grpc.max_send_message_length': MAX_GRPC_MESSAGE_BYTES,
'grpc.max_receive_message_length': MAX_GRPC_MESSAGE_BYTES,
}) as unknown as DaemonServiceClient;
}
function waitForReady(client: grpc.Client, timeoutMs: number): Promise<void> {
@@ -444,9 +443,7 @@ interface DaemonRequestTimeoutOptions {
rpcTimeoutMs?: number;
}
export async function daemonGetNodeStatus(
node: DaemonNodeConnection,
): Promise<DaemonNodeStatus> {
export async function daemonGetNodeStatus(node: DaemonNodeConnection): Promise<DaemonNodeStatus> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
@@ -466,9 +463,7 @@ export async function daemonGetNodeStatus(
}
}
export async function daemonGetNodeStats(
node: DaemonNodeConnection,
): Promise<DaemonNodeStats> {
export async function daemonGetNodeStats(node: DaemonNodeConnection): Promise<DaemonNodeStats> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
@@ -511,7 +506,8 @@ export async function daemonDeleteServer(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
(callback) =>
client.deleteServer({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
@@ -593,6 +589,30 @@ export async function daemonUpdateDatabasePassword(
}
}
export async function daemonImportDatabaseSql(
node: DaemonNodeConnection,
request: { databaseName: string; sql: string },
): Promise<void> {
const client = createClient(node);
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) =>
client.importDatabaseSql(
{
database_name: request.databaseName,
sql: request.sql,
},
getMetadata(node.daemonToken),
callback,
),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
client.close();
}
}
export async function daemonDeleteDatabase(
node: DaemonNodeConnection,
request: { databaseName: string; username: string },
@@ -626,7 +646,12 @@ export async function daemonSetPowerState(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.setPowerState({ uuid: serverUuid, action: POWER_ACTIONS[action] }, getMetadata(node.daemonToken), callback),
(callback) =>
client.setPowerState(
{ uuid: serverUuid, action: POWER_ACTIONS[action] },
getMetadata(node.daemonToken),
callback,
),
POWER_RPC_TIMEOUT_MS,
);
} finally {
@@ -643,7 +668,8 @@ export async function daemonGetServerStatus(
try {
await waitForReady(client, timeouts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
return await callUnary<DaemonStatusResponse>(
(callback) => client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
(callback) =>
client.getServerStatus({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
timeouts.rpcTimeoutMs ?? DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
@@ -688,7 +714,8 @@ export async function daemonSendCommand(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
(callback) =>
client.sendCommand({ uuid: serverUuid, command }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
@@ -705,7 +732,8 @@ export async function daemonListFiles(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileListResponseRaw>(
(callback) => client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
(callback) =>
client.listFiles({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
@@ -731,7 +759,8 @@ export async function daemonReadFile(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonFileContentRaw>(
(callback) => client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
(callback) =>
client.readFile({ uuid: serverUuid, path }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
@@ -756,7 +785,11 @@ export async function daemonWriteFile(
await callUnary<EmptyResponse>(
(callback) =>
client.writeFile(
{ uuid: serverUuid, path, data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data },
{
uuid: serverUuid,
path,
data: typeof data === 'string' ? Buffer.from(data, 'utf8') : data,
},
getMetadata(node.daemonToken),
callback,
),
@@ -776,7 +809,8 @@ export async function daemonDeleteFiles(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
await callUnary<EmptyResponse>(
(callback) => client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
(callback) =>
client.deleteFiles({ uuid: serverUuid, paths }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
} finally {
@@ -870,7 +904,8 @@ export async function daemonGetActivePlayers(
try {
await waitForReady(client, DEFAULT_CONNECT_TIMEOUT_MS);
const response = await callUnary<DaemonPlayerListRaw>(
(callback) => client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
(callback) =>
client.getActivePlayers({ uuid: serverUuid }, getMetadata(node.daemonToken), callback),
DEFAULT_RPC_TIMEOUT_MS,
);
+740
View File
@@ -0,0 +1,740 @@
import { randomBytes } from 'node:crypto';
import { gunzipSync } from 'node:zlib';
import type { FastifyInstance } from 'fastify';
import { and, asc, eq } from 'drizzle-orm';
import * as tar from 'tar-stream';
import type { Headers } from 'tar-stream';
import * as unzipper from 'unzipper';
import { serverDatabases, servers } from '@source/database';
import {
daemonCreateDatabase,
daemonDeleteDatabase,
daemonDeleteFiles,
daemonImportDatabaseSql,
daemonReadFile,
daemonWriteFile,
type DaemonNodeConnection,
} from './daemon.js';
const GITHUB_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
const URL_ARCHIVE_MAX_BYTES = 256 * 1024 * 1024;
const QBCORE_DATABASE_NAME = 'qbcore';
const FIVE_M_QBCORE_MARKER_PATH = '/.gamepanel/fivem-qbcore.json';
const FIVE_M_INTERNAL_PORT = 30120;
const QBCORE_SQL_URL =
'https://raw.githubusercontent.com/qbcore-framework/txAdminRecipe/main/qbcore.sql';
const OXMYSQL_ZIP_URL =
'https://github.com/overextended/oxmysql/releases/download/v2.12.0/oxmysql.zip';
const MENUV_ZIP_URL = 'https://github.com/ThymonA/menuv/releases/download/v1.4.1/menuv_v1.4.1.zip';
interface ExtractedFile {
path: string;
data: Buffer;
}
interface ManagedServerDatabaseRecord {
id: string;
name: string;
databaseName: string;
username: string;
password: string;
host: string;
port: number;
phpMyAdminUrl: string | null;
}
interface FivemProvisionContext {
node: DaemonNodeConnection;
serverDescription?: string | null;
serverId: string;
serverName: string;
serverUuid: string;
}
interface GitHubArchiveResource {
destination: string;
owner: string;
ref: string;
repo: string;
subpath?: string;
}
interface RemoteArchiveResource {
collapseTopLevelDirectory?: boolean;
destination: string;
url: string;
}
const FIVEM_GITHUB_RESOURCES: GitHubArchiveResource[] = [
{
owner: 'citizenfx',
repo: 'cfx-server-data',
ref: 'master',
destination: '/resources/[cfx-default]',
subpath: 'resources',
},
{
owner: 'qbcore-framework',
repo: 'bob74_ipl',
ref: 'master',
destination: '/resources/[standalone]/bob74_ipl',
},
{
owner: 'qbcore-framework',
repo: 'safecracker',
ref: 'main',
destination: '/resources/[standalone]/safecracker',
},
{
owner: 'citizenfx',
repo: 'screenshot-basic',
ref: 'master',
destination: '/resources/[standalone]/screenshot-basic',
},
{
owner: 'qbcore-framework',
repo: 'progressbar',
ref: 'main',
destination: '/resources/[standalone]/progressbar',
},
{
owner: 'qbcore-framework',
repo: 'interact-sound',
ref: 'master',
destination: '/resources/[standalone]/interact-sound',
},
{
owner: 'qbcore-framework',
repo: 'connectqueue',
ref: 'master',
destination: '/resources/[standalone]/connectqueue',
},
{
owner: 'qbcore-framework',
repo: 'PolyZone',
ref: 'master',
destination: '/resources/[standalone]/PolyZone',
},
{
owner: 'AvarianKnight',
repo: 'pma-voice',
ref: 'main',
destination: '/resources/[voice]/pma-voice',
},
{
owner: 'qbcore-framework',
repo: 'qb-radio',
ref: 'main',
destination: '/resources/[voice]/qb-radio',
},
{
owner: 'qbcore-framework',
repo: 'hospital_map',
ref: 'main',
destination: '/resources/[defaultmaps]/hospital_map',
},
{
owner: 'qbcore-framework',
repo: 'dealer_map',
ref: 'main',
destination: '/resources/[defaultmaps]/dealer_map',
},
{
owner: 'qbcore-framework',
repo: 'prison_map',
ref: 'main',
destination: '/resources/[defaultmaps]/prison_map',
},
...[
'qb-core',
'qb-scoreboard',
'qb-adminmenu',
'qb-multicharacter',
'qb-target',
'qb-vehiclesales',
'qb-vehicleshop',
'qb-houserobbery',
'qb-prison',
'qb-hud',
'qb-management',
'qb-weed',
'qb-lapraces',
'qb-inventory',
'qb-houses',
'qb-garages',
'qb-ambulancejob',
'qb-radialmenu',
'qb-crypto',
'qb-weathersync',
'qb-policejob',
'qb-apartments',
'qb-vehiclekeys',
'qb-mechanicjob',
'qb-phone',
'qb-vineyard',
'qb-weapons',
'qb-scrapyard',
'qb-towjob',
'qb-streetraces',
'qb-storerobbery',
'qb-spawn',
'qb-smallresources',
'qb-recyclejob',
'qb-crafting',
'qb-diving',
'qb-cityhall',
'qb-truckrobbery',
'qb-pawnshop',
'qb-minigames',
'qb-taxijob',
'qb-busjob',
'qb-newsjob',
'qb-fuel',
'qb-jewelery',
'qb-bankrobbery',
'qb-banking',
'qb-clothing',
'qb-hotdogjob',
'qb-doorlock',
'qb-garbagejob',
'qb-drugs',
'qb-shops',
'qb-interior',
'qb-menu',
'qb-input',
'qb-loading',
].map((repo) => ({
owner: 'qbcore-framework',
repo,
ref: 'main',
destination: `/resources/[qb]/${repo}`,
})),
];
const FIVEM_REMOTE_ARCHIVES: RemoteArchiveResource[] = [
{
url: OXMYSQL_ZIP_URL,
destination: '/resources/[standalone]/oxmysql',
collapseTopLevelDirectory: true,
},
{
url: MENUV_ZIP_URL,
destination: '/resources/[standalone]/menuv',
collapseTopLevelDirectory: true,
},
];
function normalizePathSegments(path: string): string[] {
return path
.replace(/\\/g, '/')
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..');
}
function normalizeArchivePath(path: string): string | null {
const segments = normalizePathSegments(path);
if (segments.length === 0) return null;
return segments.join('/');
}
function joinServerPath(base: string, relative: string): string {
const baseSegments = normalizePathSegments(base);
const relativeSegments = normalizePathSegments(relative);
return `/${[...baseSegments, ...relativeSegments].join('/')}`.replace(/\/{2,}/g, '/');
}
function stripSharedTopLevelDirectory(files: ExtractedFile[]): ExtractedFile[] {
if (files.length === 0) return files;
const firstSegments = new Set<string>();
for (const file of files) {
const [first] = normalizePathSegments(file.path);
if (!first) return files;
firstSegments.add(first);
if (firstSegments.size > 1) {
return files;
}
}
return files
.map((file) => {
const segments = normalizePathSegments(file.path).slice(1);
if (segments.length === 0) return null;
return {
path: segments.join('/'),
data: file.data,
};
})
.filter((file): file is ExtractedFile => file !== null);
}
function filterFilesBySubpath(files: ExtractedFile[], subpath: string): ExtractedFile[] {
const prefix = normalizePathSegments(subpath).join('/');
if (!prefix) return files;
const normalizedPrefix = `${prefix}/`;
return files
.map((file) => {
if (file.path === prefix) return null;
if (!file.path.startsWith(normalizedPrefix)) return null;
return {
path: file.path.slice(normalizedPrefix.length),
data: file.data,
};
})
.filter((file): file is ExtractedFile => file !== null && file.path.length > 0);
}
async function downloadBinary(
url: string,
maxBytes: number,
headers: Record<string, string> = {},
): Promise<Buffer> {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
...headers,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Download failed (${response.status}): ${url}`);
}
const contentLength = Number(response.headers.get('content-length') ?? '0');
if (contentLength > maxBytes) {
throw new Error(`Download exceeds size limit (${contentLength} > ${maxBytes})`);
}
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) {
throw new Error(`Downloaded archive is empty: ${url}`);
}
if (buffer.length > maxBytes) {
throw new Error(`Download exceeds size limit (${buffer.length} > ${maxBytes})`);
}
return buffer;
}
async function downloadText(url: string, headers: Record<string, string> = {}): Promise<string> {
const response = await fetch(url, {
headers: {
'User-Agent': 'SourceGamePanel/1.0',
...headers,
},
redirect: 'follow',
});
if (!response.ok) {
throw new Error(`Text download failed (${response.status}): ${url}`);
}
const text = await response.text();
if (!text.trim()) {
throw new Error(`Downloaded text is empty: ${url}`);
}
return text;
}
async function extractZipFiles(buffer: Buffer): Promise<ExtractedFile[]> {
const archive = await unzipper.Open.buffer(buffer);
const files: ExtractedFile[] = [];
for (const entry of archive.files) {
if (entry.type !== 'File') continue;
const normalized = normalizeArchivePath(entry.path);
if (!normalized) continue;
files.push({
path: normalized,
data: await entry.buffer(),
});
}
return files;
}
function extractTarFiles(buffer: Buffer): Promise<ExtractedFile[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const files: ExtractedFile[] = [];
extract.on('entry', (header: Headers, stream, next) => {
const type = header.type ?? 'file';
const normalized = normalizeArchivePath(header.name);
const isFileType = type === 'file' || type === 'contiguous-file';
if (!isFileType || !normalized) {
stream.resume();
stream.on('end', next);
stream.on('error', reject);
return;
}
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('end', () => {
files.push({ path: normalized, data: Buffer.concat(chunks) });
next();
});
stream.on('error', reject);
});
extract.on('finish', () => resolve(files));
extract.on('error', reject);
extract.end(buffer);
});
}
async function extractArchive(buffer: Buffer, url: string): Promise<ExtractedFile[]> {
const normalizedUrl = url.toLowerCase();
if (normalizedUrl.endsWith('.zip')) {
return extractZipFiles(buffer);
}
if (normalizedUrl.endsWith('.tar.gz') || normalizedUrl.endsWith('.tgz')) {
return extractTarFiles(gunzipSync(buffer));
}
if (normalizedUrl.endsWith('.tar')) {
return extractTarFiles(buffer);
}
throw new Error(`Unsupported archive type: ${url}`);
}
async function writeFilesToServer(
node: DaemonNodeConnection,
serverUuid: string,
destination: string,
files: ExtractedFile[],
): Promise<void> {
for (const file of files) {
await daemonWriteFile(node, serverUuid, joinServerPath(destination, file.path), file.data);
}
}
function escapeCfgValue(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function buildMysqlConnectionString(database: ManagedServerDatabaseRecord): string {
return `mysql://${encodeURIComponent(database.username)}:${encodeURIComponent(database.password)}@${database.host}:${database.port}/${encodeURIComponent(database.databaseName)}?charset=utf8mb4`;
}
function renderFivemServerConfig(
serverName: string,
description: string | null | undefined,
database: ManagedServerDatabaseRecord,
): string {
const safeServerName = escapeCfgValue(serverName.trim() || 'QBCore Server');
const safeProjectDescription = escapeCfgValue(
description?.trim() || 'QBCore server provisioned by Source GamePanel.',
);
const rconPassword = randomBytes(16).toString('hex');
const mysqlConnectionString = escapeCfgValue(buildMysqlConnectionString(database));
return `# Generated by Source GamePanel
# QBCore resources and base dependencies are installed automatically.
endpoint_add_tcp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
endpoint_add_udp "0.0.0.0:${FIVE_M_INTERNAL_PORT}"
sv_maxclients "32"
sv_hostname "${safeServerName}"
sets sv_projectName "[QBCore] ${safeServerName}"
sets sv_projectDesc "${safeProjectDescription}"
sets locale "en-US"
sets tags "qbcore, qb-core, roleplay, source-gamepanel"
set steam_webApiKey "none"
set resources_useSystemChat "true"
set mysql_connection_string "${mysqlConnectionString}"
setr qb_locale "en"
setr UseTarget "false"
setr voice_useNativeAudio "true"
setr voice_useSendingRangeOnly "true"
setr voice_defaultCycle "GRAVE"
setr voice_defaultVolume "0.3"
setr voice_enableRadioAnim "1"
setr voice_syncData "1"
sv_scriptHookAllowed "0"
sv_endpointprivacy "true"
rcon_password "${rconPassword}"
ensure mapmanager
ensure chat
ensure spawnmanager
ensure sessionmanager
ensure basic-gamemode
ensure hardcap
ensure baseevents
ensure qb-core
ensure [qb]
ensure [standalone]
ensure [voice]
ensure [defaultmaps]
add_ace group.admin command allow
add_ace group.admin command.quit deny
add_ace resource.qb-core command allow
add_ace qbcore.god command allow
add_principal qbcore.god group.admin
add_principal qbcore.god qbcore.admin
add_principal qbcore.admin qbcore.mod
`;
}
function isMissingFileError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('No such file or directory') ||
message.includes('Server responded with NOT_FOUND') ||
message.includes('status code 404')
);
}
export function isFivemQbCoreGame(gameSlug: string): boolean {
return gameSlug.trim().toLowerCase() === 'fivem';
}
export async function ensureFivemQbCoreDatabase(
app: FastifyInstance,
context: Pick<FivemProvisionContext, 'node' | 'serverId' | 'serverUuid'>,
): Promise<ManagedServerDatabaseRecord> {
const existing = await app.db
.select({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
})
.from(serverDatabases)
.where(eq(serverDatabases.serverId, context.serverId))
.orderBy(asc(serverDatabases.createdAt));
const preferred =
existing.find((database) => database.name.trim().toLowerCase() === QBCORE_DATABASE_NAME) ??
existing[0];
if (preferred) {
return preferred;
}
const managedDatabase = await daemonCreateDatabase(context.node, {
serverUuid: context.serverUuid,
name: QBCORE_DATABASE_NAME,
});
try {
const [created] = await app.db
.insert(serverDatabases)
.values({
serverId: context.serverId,
name: QBCORE_DATABASE_NAME,
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
password: managedDatabase.password,
host: managedDatabase.host,
port: managedDatabase.port,
phpMyAdminUrl: managedDatabase.phpMyAdminUrl,
})
.returning({
id: serverDatabases.id,
name: serverDatabases.name,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
password: serverDatabases.password,
host: serverDatabases.host,
port: serverDatabases.port,
phpMyAdminUrl: serverDatabases.phpMyAdminUrl,
});
if (!created) {
throw new Error('Failed to persist managed database metadata');
}
return created;
} catch (error) {
try {
await daemonDeleteDatabase(context.node, {
databaseName: managedDatabase.databaseName,
username: managedDatabase.username,
});
} catch (cleanupError) {
app.log.error(
{
cleanupError,
databaseName: managedDatabase.databaseName,
serverId: context.serverId,
serverUuid: context.serverUuid,
},
'Failed to roll back managed MySQL database after metadata save failure',
);
}
throw error;
}
}
export async function deleteFivemQbCoreDatabase(
app: FastifyInstance,
context: Pick<FivemProvisionContext, 'node' | 'serverId'>,
): Promise<void> {
const [database] = await app.db
.select({
id: serverDatabases.id,
databaseName: serverDatabases.databaseName,
username: serverDatabases.username,
})
.from(serverDatabases)
.where(
and(
eq(serverDatabases.serverId, context.serverId),
eq(serverDatabases.name, QBCORE_DATABASE_NAME),
),
);
if (!database) return;
await daemonDeleteDatabase(context.node, {
databaseName: database.databaseName,
username: database.username,
});
await app.db.delete(serverDatabases).where(eq(serverDatabases.id, database.id));
}
async function installGitHubResource(
app: FastifyInstance,
context: FivemProvisionContext,
resource: GitHubArchiveResource,
): Promise<void> {
const archiveUrl = `https://codeload.github.com/${resource.owner}/${resource.repo}/tar.gz/refs/heads/${encodeURIComponent(resource.ref)}`;
const archive = await downloadBinary(archiveUrl, GITHUB_ARCHIVE_MAX_BYTES);
let files = await extractTarFiles(gunzipSync(archive));
files = stripSharedTopLevelDirectory(files);
if (resource.subpath) {
files = filterFilesBySubpath(files, resource.subpath);
}
if (files.length === 0) {
throw new Error(
`GitHub archive had no files: ${resource.owner}/${resource.repo}@${resource.ref}`,
);
}
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
app.log.info(
{
destination: resource.destination,
filesWritten: files.length,
repo: `${resource.owner}/${resource.repo}`,
serverId: context.serverId,
serverUuid: context.serverUuid,
},
'Installed FiveM GitHub resource',
);
}
async function installRemoteArchive(
app: FastifyInstance,
context: FivemProvisionContext,
resource: RemoteArchiveResource,
): Promise<void> {
const archive = await downloadBinary(resource.url, URL_ARCHIVE_MAX_BYTES);
let files = await extractArchive(archive, resource.url);
if (resource.collapseTopLevelDirectory) {
files = stripSharedTopLevelDirectory(files);
}
if (files.length === 0) {
throw new Error(`Remote archive had no files: ${resource.url}`);
}
await writeFilesToServer(context.node, context.serverUuid, resource.destination, files);
app.log.info(
{
destination: resource.destination,
filesWritten: files.length,
serverId: context.serverId,
serverUuid: context.serverUuid,
url: resource.url,
},
'Installed FiveM remote archive',
);
}
export async function provisionFivemQbCoreServer(
app: FastifyInstance,
context: FivemProvisionContext,
): Promise<void> {
try {
await daemonReadFile(context.node, context.serverUuid, FIVE_M_QBCORE_MARKER_PATH);
return;
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
const database = await ensureFivemQbCoreDatabase(app, context);
const qbCoreSql = await downloadText(QBCORE_SQL_URL);
await daemonImportDatabaseSql(context.node, {
databaseName: database.databaseName,
sql: qbCoreSql,
});
for (const resource of FIVEM_GITHUB_RESOURCES) {
await installGitHubResource(app, context, resource);
}
for (const resource of FIVEM_REMOTE_ARCHIVES) {
await installRemoteArchive(app, context, resource);
}
try {
await daemonDeleteFiles(context.node, context.serverUuid, [
'/resources/[cfx-default]/[gameplay]/chat',
]);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
}
await daemonWriteFile(
context.node,
context.serverUuid,
'/server.cfg',
renderFivemServerConfig(context.serverName, context.serverDescription, database),
);
await daemonWriteFile(
context.node,
context.serverUuid,
FIVE_M_QBCORE_MARKER_PATH,
JSON.stringify(
{
installedAt: new Date().toISOString(),
manifestVersion: 1,
resourceCount: FIVEM_GITHUB_RESOURCES.length + FIVEM_REMOTE_ARCHIVES.length,
},
null,
2,
),
);
await app.db
.update(servers)
.set({ updatedAt: new Date() })
.where(eq(servers.id, context.serverId));
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,6 +17,7 @@ export const CreateServerSchema = {
diskLimit: Type.Number({ minimum: 256 * 1024 * 1024 }), // min 256MB
cpuLimit: Type.Optional(Type.Number({ minimum: 10, maximum: 10000, default: 100 })),
allocationId: Type.String({ format: 'uuid' }),
additionalAllocationIds: Type.Optional(Type.Array(Type.String({ format: 'uuid' }))),
environment: Type.Optional(Type.Record(Type.String(), Type.String())),
startupOverride: Type.Optional(Type.String()),
}),
+223 -3
View File
@@ -1,26 +1,38 @@
use std::collections::HashMap;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use bollard::container::{
AttachContainerOptions, Config, CreateContainerOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
StopContainerOptions, StatsOptions, Stats,
AttachContainerOptions, Config, CreateContainerOptions, ListContainersOptions, LogsOptions, RemoveContainerOptions, StartContainerOptions,
StopContainerOptions, StatsOptions, Stats, UploadToContainerOptions,
};
use bollard::image::CreateImageOptions;
use bollard::models::{HostConfig, PortBinding};
use bollard::models::{HostConfig, MountPointTypeEnum, PortBinding};
use futures::StreamExt;
use tokio::time::{sleep, Duration};
use tracing::{debug, info};
use crate::docker::DockerManager;
use crate::server::ServerSpec;
use crate::server::state::ServerState;
/// Container name prefix for all managed game servers.
const CONTAINER_PREFIX: &str = "gp_";
const SATISFACTORY_RUN_SH: &str = include_str!("../game/satisfactory_run.sh");
pub fn container_name(server_uuid: &str) -> String {
format!("{}{}", CONTAINER_PREFIX, server_uuid)
}
fn uuid_from_container_name(name: &str) -> Option<String> {
let trimmed = name.trim_start_matches('/');
trimmed
.strip_prefix(CONTAINER_PREFIX)
.filter(|uuid| !uuid.is_empty())
.map(str::to_string)
}
fn container_data_path_for_image(image: &str) -> &'static str {
let normalized = image.to_ascii_lowercase();
if normalized.contains("cm2network/cs2") || normalized.contains("joedwards32/cs2") {
@@ -29,9 +41,30 @@ fn container_data_path_for_image(image: &str) -> &'static str {
if normalized.contains("cm2network/csgo") {
return "/home/steam/csgo-dedicated";
}
if normalized.contains("spritsail/fivem") {
return "/config";
}
if normalized.contains("wolveix/satisfactory-server") {
return "/config";
}
"/data"
}
fn is_wolveix_satisfactory_image(image: &str) -> bool {
image
.to_ascii_lowercase()
.contains("wolveix/satisfactory-server")
}
fn server_state_from_container_status(status: &str) -> ServerState {
match status {
"running" | "restarting" | "paused" => ServerState::Running,
"created" | "exited" => ServerState::Stopped,
"dead" => ServerState::Error,
_ => ServerState::Error,
}
}
impl DockerManager {
async fn attach_command_stream(
&self,
@@ -136,6 +169,33 @@ impl DockerManager {
Err(anyhow::anyhow!("exec command timeout"))
}
async fn patch_satisfactory_run_script(&self, container_name: &str) -> Result<()> {
let mut archive = Vec::new();
{
let mut builder = tar::Builder::new(&mut archive);
let bytes = SATISFACTORY_RUN_SH.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append_data(&mut header, "run.sh", Cursor::new(bytes))?;
builder.finish()?;
}
self.client()
.upload_to_container(
container_name,
Some(UploadToContainerOptions {
path: "/home/steam",
no_overwrite_dir_non_dir: "false",
}),
archive.into(),
)
.await?;
Ok(())
}
pub async fn rcon_command(&self, server_uuid: &str, command: &str) -> Result<String> {
let name = container_name(server_uuid);
self.run_exec(&name, vec!["rcon-cli".to_string(), command.to_string()])
@@ -248,6 +308,10 @@ impl DockerManager {
let options = CreateContainerOptions { name: name.as_str(), platform: None };
let response = self.client().create_container(Some(options), config).await?;
if is_wolveix_satisfactory_image(&spec.docker_image) {
self.patch_satisfactory_run_script(&name).await?;
}
info!(container_id = %response.id, uuid = %spec.uuid, "Container created");
Ok(response.id)
}
@@ -380,6 +444,162 @@ impl DockerManager {
Ok((image, env_map))
}
/// Discover existing managed containers and rebuild in-memory server specs.
pub async fn recover_managed_server_specs(&self, data_root: &Path) -> Result<Vec<ServerSpec>> {
let containers = self
.client()
.list_containers(Some(ListContainersOptions::<String> {
all: true,
..Default::default()
}))
.await?;
let mut recovered = Vec::new();
for container in containers {
let uuid = container
.names
.as_ref()
.into_iter()
.flatten()
.find_map(|name| uuid_from_container_name(name));
let Some(uuid) = uuid else {
continue;
};
let info = self.client().inspect_container(&container_name(&uuid), None).await?;
let image = info
.config
.as_ref()
.and_then(|cfg| cfg.image.clone())
.unwrap_or_default();
let data_mount_path = info
.mounts
.as_ref()
.and_then(|mounts| {
mounts.iter().find_map(|mount| {
if mount.typ != Some(MountPointTypeEnum::BIND) {
return None;
}
mount.source.as_ref().map(PathBuf::from)
})
})
.unwrap_or_else(|| data_root.join(&uuid));
let data_destination = info
.mounts
.as_ref()
.and_then(|mounts| {
mounts.iter().find_map(|mount| {
if mount.typ != Some(MountPointTypeEnum::BIND) {
return None;
}
mount.destination.clone()
})
})
.unwrap_or_else(|| container_data_path_for_image(&image).to_string());
let startup_command = info
.config
.as_ref()
.and_then(|cfg| {
let working_dir = cfg.working_dir.as_deref().unwrap_or_default();
if working_dir != data_destination {
return None;
}
cfg.cmd.as_ref().map(|cmd| cmd.join(" "))
})
.unwrap_or_default();
let mut environment = HashMap::new();
if let Some(env_vars) = info
.config
.as_ref()
.and_then(|cfg| cfg.env.clone())
{
for entry in env_vars {
if let Some((key, value)) = entry.split_once('=') {
environment.insert(key.to_string(), value.to_string());
}
}
}
let ports = info
.host_config
.as_ref()
.and_then(|cfg| cfg.port_bindings.as_ref())
.map(|bindings| {
bindings
.iter()
.flat_map(|(container_port, host_bindings)| {
let (container_port, protocol) = match container_port.split_once('/') {
Some((port, protocol)) => (port, protocol),
None => return Vec::new(),
};
let Ok(container_port_num) = container_port.parse::<u16>() else {
return Vec::new();
};
host_bindings
.as_ref()
.into_iter()
.flatten()
.filter_map(|binding| {
let host_port = binding.host_port.as_deref()?.parse::<u16>().ok()?;
Some(crate::server::PortMap {
host_port,
container_port: container_port_num,
protocol: protocol.to_string(),
})
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let memory_limit = info
.host_config
.as_ref()
.and_then(|cfg| cfg.memory)
.unwrap_or_default();
let cpu_limit = info
.host_config
.as_ref()
.and_then(|cfg| cfg.nano_cpus)
.map(|nano_cpus| (nano_cpus / 10_000_000) as i32)
.unwrap_or_default();
let state = info
.state
.as_ref()
.and_then(|state| state.status.as_ref())
.map(|status| server_state_from_container_status(&format!("{status:?}").to_lowercase()))
.unwrap_or(ServerState::Error);
recovered.push(ServerSpec {
uuid,
docker_image: image,
memory_limit,
disk_limit: 0,
cpu_limit,
startup_command,
environment,
ports,
data_path: data_mount_path,
state,
container_id: info.id,
});
}
Ok(recovered)
}
/// Stream container logs (stdout + stderr). Returns an owned stream.
pub fn stream_logs(
self: &Arc<Self>,
+157
View File
@@ -0,0 +1,157 @@
#!/bin/bash
set -e
NUMCHECK='^[0-9]+$'
MSGWARNING="\033[0;33mWARNING:\033[0m"
if ! [[ "$SERVERGAMEPORT" =~ $NUMCHECK ]]; then
printf "Invalid server port given: %s\n" "$SERVERGAMEPORT"
SERVERGAMEPORT="7777"
fi
printf "Setting server port to %s\n" "$SERVERGAMEPORT"
if ! [[ "$SERVERMESSAGINGPORT" =~ $NUMCHECK ]]; then
printf "Invalid messaging port given: %s\n" "$SERVERMESSAGINGPORT"
SERVERMESSAGINGPORT="8888"
fi
printf "Setting messaging port to %s\n" "$SERVERMESSAGINGPORT"
if ! [[ "$AUTOSAVENUM" =~ $NUMCHECK ]]; then
printf "Invalid autosave number given: %s\n" "$AUTOSAVENUM"
AUTOSAVENUM="5"
fi
printf "Setting autosave number to %s\n" "$AUTOSAVENUM"
if ! [[ "$MAXOBJECTS" =~ $NUMCHECK ]]; then
printf "Invalid max objects number given: %s\n" "$MAXOBJECTS"
MAXOBJECTS="2162688"
fi
printf "Setting max objects to %s\n" "$MAXOBJECTS"
if ! [[ "$MAXTICKRATE" =~ $NUMCHECK ]]; then
printf "Invalid max tick rate number given: %s\n" "$MAXTICKRATE"
MAXTICKRATE="30"
fi
printf "Setting max tick rate to %s\n" "$MAXTICKRATE"
[[ "${SERVERSTREAMING,,}" == "true" ]] && SERVERSTREAMING="1" || SERVERSTREAMING="0"
printf "Setting server streaming to %s\n" "$SERVERSTREAMING"
if ! [[ "$TIMEOUT" =~ $NUMCHECK ]]; then
printf "Invalid timeout number given: %s\n" "$TIMEOUT"
TIMEOUT="30"
fi
printf "Setting timeout to %s\n" "$TIMEOUT"
if ! [[ "$MAXPLAYERS" =~ $NUMCHECK ]]; then
printf "Invalid max players given: %s\n" "$MAXPLAYERS"
MAXPLAYERS="4"
fi
printf "Setting max players to %s\n" "$MAXPLAYERS"
if [[ "${DISABLESEASONALEVENTS,,}" == "true" ]]; then
printf "Disabling seasonal events\n"
DISABLESEASONALEVENTS="-DisableSeasonalEvents"
else
DISABLESEASONALEVENTS=""
fi
if [[ "$MULTIHOME" != "" ]]; then
if [[ "$MULTIHOME" == "::" ]]; then
printf "Multihome will accept IPv4 and IPv6 connections\n"
fi
printf "Setting multihome to %s\n" "$MULTIHOME"
MULTIHOME="-multihome=$MULTIHOME"
fi
ini_args=(
"-ini:Engine:[/Script/FactoryGame.FGSaveSession]:mNumRotatingAutosaves=$AUTOSAVENUM"
"-ini:Engine:[/Script/Engine.GarbageCollectionSettings]:gc.MaxObjectsInEditor=$MAXOBJECTS"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:LanServerMaxTickRate=$MAXTICKRATE"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:NetServerMaxTickRate=$MAXTICKRATE"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:ConnectionTimeout=$TIMEOUT"
"-ini:Engine:[/Script/OnlineSubsystemUtils.IpNetDriver]:InitialConnectTimeout=$TIMEOUT"
"-ini:Engine:[ConsoleVariables]:wp.Runtime.EnableServerStreaming=$SERVERSTREAMING"
"-ini:Game:[/Script/Engine.GameSession]:ConnectionTimeout=$TIMEOUT"
"-ini:Game:[/Script/Engine.GameSession]:InitialConnectTimeout=$TIMEOUT"
"-ini:Game:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
"-ini:GameUserSettings:[/Script/Engine.GameSession]:MaxPlayers=$MAXPLAYERS"
"$DISABLESEASONALEVENTS"
"$MULTIHOME"
)
if [[ "${SKIPUPDATE,,}" != "false" ]] && [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
printf "%s Skip update is set, but no game files exist. Updating anyway\n" "$MSGWARNING"
SKIPUPDATE="false"
fi
if [[ "${SKIPUPDATE,,}" != "true" ]]; then
STEAMBETAPASSWORD=""
if [[ -n "${STEAMBETAID}" ]]; then
printf "STEAMBETAID is set. Using beta ID: %s\n" "$STEAMBETAID"
STEAMBETAFLAG="$STEAMBETAID"
if [[ -n "${STEAMBETAKEY}" ]]; then
STEAMBETAPASSWORD="-betapassword $STEAMBETAKEY"
printf "Beta password provided\n"
fi
elif [[ "${STEAMBETA,,}" == "true" ]]; then
printf "Experimental flag is set. Experimental will be downloaded instead of Early Access.\n"
STEAMBETAFLAG="experimental"
else
STEAMBETAFLAG=""
fi
STORAGEAVAILABLE=$(stat -f -c "%a*%S" .)
STORAGEAVAILABLE=$((STORAGEAVAILABLE/1024/1024/1024))
printf "Checking available storage: %sGB detected\n" "$STORAGEAVAILABLE"
if [[ "$STORAGEAVAILABLE" -lt 8 ]]; then
printf "You have less than 8GB (%sGB detected) of available storage to download the game.\nIf this is a fresh install, it will probably fail.\n" "$STORAGEAVAILABLE"
fi
printf "\nDownloading the latest version of the game...\n"
if [ -f "/config/gamefiles/steamapps/appmanifest_1690800.acf" ]; then
printf "\nRemoving the app manifest to force Steam to check for an update...\n"
rm "/config/gamefiles/steamapps/appmanifest_1690800.acf" || true
fi
if [[ -n "$STEAMBETAFLAG" ]]; then
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" -beta "$STEAMBETAFLAG" $STEAMBETAPASSWORD validate +quit
else
steamcmd +force_install_dir /config/gamefiles +login anonymous +app_update "$STEAMAPPID" validate +quit
fi
cp -r /home/steam/.steam/steam/logs/* "/config/logs/steam" || printf "Failed to store Steam logs\n"
else
printf "Skipping update as flag is set\n"
fi
printf "Launching game server\n\n"
cp -r "/config/saved/server/." "/config/backups/" 2>/dev/null || true
cp -r "${GAMESAVESDIR}/server/." "/config/backups" 2>/dev/null || true
rm -rf "$GAMESAVESDIR"
ln -sf "/config/saved" "$GAMESAVESDIR"
if [ ! -f "/config/gamefiles/FactoryServer.sh" ]; then
printf "FactoryServer launch script is missing.\n"
exit 1
fi
cd /config/gamefiles || exit 1
chmod +x FactoryServer.sh || true
./FactoryServer.sh -Port="$SERVERGAMEPORT" -ReliablePort="$SERVERMESSAGINGPORT" -ExternalReliablePort="$SERVERMESSAGINGPORT" "${ini_args[@]}" "$@" &
sleep 2
satisfactory_pid=$(ps --ppid ${!} o pid=)
shutdown() {
printf "\nReceived SIGINT. Shutting down.\n"
kill -INT $satisfactory_pid 2>/dev/null
}
trap shutdown SIGINT SIGTERM
wait
+22
View File
@@ -303,6 +303,28 @@ impl DaemonService for DaemonServiceImpl {
}))
}
async fn import_database_sql(
&self,
request: Request<ImportDatabaseSqlRequest>,
) -> Result<Response<Empty>, Status> {
self.check_auth(&request)?;
let req = request.into_inner();
if req.database_name.trim().is_empty() {
return Err(Status::invalid_argument("Database name is required"));
}
if req.sql.trim().is_empty() {
return Err(Status::invalid_argument("SQL payload is required"));
}
self.managed_mysql
.import_sql(req.database_name.trim(), &req.sql)
.await
.map_err(Status::from)?;
Ok(Response::new(Empty {}))
}
async fn update_database_password(
&self,
request: Request<UpdateDatabasePasswordRequest>,
+3
View File
@@ -49,6 +49,9 @@ async fn main() -> Result<()> {
let server_manager = Arc::new(ServerManager::new(docker, &config));
info!("Server manager initialized");
let recovered_servers = server_manager.recover_existing_servers().await?;
info!(recovered_servers, "Recovered managed servers from Docker");
// Initialize shared command dispatcher (single command pipeline for all games/sources)
let command_dispatcher = Arc::new(CommandDispatcher::new(server_manager.clone()));
info!("Command dispatcher initialized");
+94
View File
@@ -1,7 +1,9 @@
use std::io::ErrorKind;
use std::process::Stdio;
use reqwest::Url;
use thiserror::Error;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tonic::Status;
use uuid::Uuid;
@@ -164,6 +166,28 @@ impl ManagedMysqlManager {
.await
}
pub async fn import_sql(
&self,
database_name: &str,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let config = self.config.as_ref().ok_or(ManagedMysqlError::NotConfigured)?;
let database_name = database_name.trim();
if database_name.is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"Database name is required".to_string(),
));
}
if sql.trim().is_empty() {
return Err(ManagedMysqlError::CommandFailed(
"SQL payload is required".to_string(),
));
}
self.run_sql_script(config, database_name, sql).await
}
pub async fn delete_database(
&self,
database_name: &str,
@@ -242,6 +266,76 @@ impl ManagedMysqlManager {
Err(ManagedMysqlError::ClientMissing)
}
async fn run_sql_script(
&self,
config: &ManagedMysqlRuntimeConfig,
database_name: &str,
sql: &str,
) -> Result<(), ManagedMysqlError> {
let binaries = match config.client_bin.as_deref() {
Some(bin) if !bin.trim().is_empty() => vec![bin.to_string()],
_ => vec!["mariadb".to_string(), "mysql".to_string()],
};
let mut missing_binary = false;
for binary in binaries {
let child = Command::new(&binary)
.args([
"--protocol=TCP",
"--batch",
"--skip-column-names",
"-h",
&config.admin_host,
"-P",
&config.admin_port.to_string(),
"-u",
&config.admin_username,
database_name,
])
.env("MYSQL_PWD", &config.admin_password)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
match child {
Ok(mut child) => {
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(sql.as_bytes()).await?;
}
let output = child.wait_with_output().await?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let message = if !stderr.is_empty() {
stderr
} else if !stdout.is_empty() {
stdout
} else {
format!("{} exited with status {}", binary, output.status)
};
return Err(ManagedMysqlError::CommandFailed(message));
}
Err(error) if error.kind() == ErrorKind::NotFound => {
missing_binary = true;
continue;
}
Err(error) => return Err(ManagedMysqlError::Io(error)),
}
}
if missing_binary {
return Err(ManagedMysqlError::ClientMissing);
}
Err(ManagedMysqlError::ClientMissing)
}
}
fn resolve_runtime_config(
+30
View File
@@ -49,6 +49,32 @@ impl ServerManager {
}
}
/// Rebuild in-memory server specs from existing managed Docker containers.
pub async fn recover_existing_servers(&self) -> Result<usize, DaemonError> {
let recovered = self
.docker
.recover_managed_server_specs(&self.data_root)
.await
.map_err(|error| DaemonError::Internal(format!("Failed to recover managed containers: {}", error)))?;
let recovered_count = recovered.len();
let mut servers = self.servers.write().await;
servers.clear();
for spec in recovered {
self.ensure_server_data_dir(&spec.data_path).await?;
info!(
uuid = %spec.uuid,
state = %spec.state,
image = %spec.docker_image,
"Recovered managed server from Docker runtime"
);
servers.insert(spec.uuid.clone(), spec);
}
Ok(recovered_count)
}
/// Get server spec by UUID.
pub async fn get_server(&self, uuid: &str) -> Result<ServerSpec, DaemonError> {
let servers = self.servers.read().await;
@@ -200,6 +226,10 @@ impl ServerManager {
})?;
}
self.docker.pull_image(&desired_spec.docker_image).await.map_err(|e| {
DaemonError::Internal(format!("Failed to pull updated image during server update: {}", e))
})?;
match self.docker.create_container(&desired_spec).await {
Ok(container_id) => {
desired_spec.container_id = Some(container_id);
+239 -8
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
@@ -7,15 +7,34 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface Game {
environmentVars?: GameEnvironmentVar[];
id: string;
name: string;
slug: string;
dockerImage: string;
}
interface GameEnvironmentVar {
key: string;
label?: string;
default?: string;
description?: string;
required?: boolean;
inputType?: 'text' | 'boolean';
composeInto?: string;
enabledLabel?: string;
disabledLabel?: string;
}
interface Node {
id: string;
name: string;
@@ -36,6 +55,28 @@ interface PaginatedResponse<T> {
meta: { total: number };
}
interface AdditionalPortRequirement {
key: string;
label: string;
defaultPort: number;
protocols: Array<'tcp' | 'udp'>;
description: string;
}
function additionalPortRequirementsForGame(gameSlug: string): AdditionalPortRequirement[] {
if (gameSlug.trim().toLowerCase() !== 'satisfactory') return [];
return [
{
key: 'satisfactory-messaging',
label: 'Messaging Port',
defaultPort: 8888,
protocols: ['tcp'],
description: 'Required by the Satisfactory server messaging API.',
},
];
}
export function CreateServerPage() {
const { orgId } = useParams();
const navigate = useNavigate();
@@ -46,9 +87,13 @@ export function CreateServerPage() {
const [gameId, setGameId] = useState('');
const [nodeId, setNodeId] = useState('');
const [allocationId, setAllocationId] = useState('');
const [additionalAllocationIds, setAdditionalAllocationIds] = useState<Record<string, string>>(
{},
);
const [memoryLimit, setMemoryLimit] = useState(1024);
const [diskLimit, setDiskLimit] = useState(5120);
const [cpuLimit, setCpuLimit] = useState(100);
const [environment, setEnvironment] = useState<Record<string, string>>({});
const { data: gamesData } = useQuery({
queryKey: ['games'],
@@ -63,9 +108,7 @@ export function CreateServerPage() {
const { data: allocationsData } = useQuery({
queryKey: ['allocations', orgId, nodeId],
queryFn: () =>
api.get<PaginatedResponse<Allocation>>(
`/organizations/${orgId}/nodes/${nodeId}/allocations`,
),
api.get<PaginatedResponse<Allocation>>(`/organizations/${orgId}/nodes/${nodeId}/allocations`),
enabled: !!nodeId,
});
@@ -81,8 +124,60 @@ export function CreateServerPage() {
const games = gamesData?.data ?? [];
const nodes = nodesData?.data ?? [];
const activeGame = games.find((game) => game.id === gameId);
const additionalPortRequirements = activeGame
? additionalPortRequirementsForGame(activeGame.slug)
: [];
const visibleEnvironmentVars = (activeGame?.environmentVars ?? []).filter((variable) => {
const key = variable.key?.trim();
return Boolean(key) && !variable.composeInto?.trim();
});
const missingRequiredEnvironment = visibleEnvironmentVars.some((variable) => {
const key = variable.key.trim();
const currentValue = environment[key] ?? String(variable.default ?? '');
return Boolean(variable.required) && !currentValue.trim();
});
const missingRequiredAdditionalPorts = additionalPortRequirements.some(
(requirement) => !additionalAllocationIds[requirement.key],
);
useEffect(() => {
if (!activeGame) {
setEnvironment({});
return;
}
setEnvironment((current) => {
const next: Record<string, string> = {};
for (const variable of activeGame.environmentVars ?? []) {
const key = variable.key?.trim();
if (!key || variable.composeInto?.trim()) continue;
next[key] = current[key] ?? String(variable.default ?? '');
}
return next;
});
setAdditionalAllocationIds({});
if (activeGame.slug.trim().toLowerCase() === 'satisfactory') {
setMemoryLimit((current) => Math.max(current, 8192));
setDiskLimit((current) => Math.max(current, 12288));
}
}, [activeGame?.id]);
useEffect(() => {
setAdditionalAllocationIds((current) => {
const allowedKeys = new Set(additionalPortRequirements.map((requirement) => requirement.key));
const next = Object.fromEntries(
Object.entries(current).filter(([key]) => allowedKeys.has(key)),
);
return next;
});
}, [activeGame?.slug]);
const handleCreate = () => {
const environmentPayload = Object.fromEntries(
Object.entries(environment).filter(([, value]) => value.trim() !== ''),
);
createMutation.mutate({
name,
description: description || undefined,
@@ -92,9 +187,30 @@ export function CreateServerPage() {
memoryLimit: memoryLimit * 1024 * 1024,
diskLimit: diskLimit * 1024 * 1024,
cpuLimit,
additionalAllocationIds:
additionalPortRequirements.length > 0
? additionalPortRequirements
.map((requirement) => additionalAllocationIds[requirement.key])
.filter(Boolean)
: undefined,
environment: Object.keys(environmentPayload).length > 0 ? environmentPayload : undefined,
});
};
const allocationOptionsForRequirement = (requirementKey: string) => {
const selectedByOtherRequirements = new Set(
Object.entries(additionalAllocationIds)
.filter(([key]) => key !== requirementKey)
.map(([, id]) => id)
.filter(Boolean),
);
return freeAllocations.filter(
(allocation) =>
allocation.id !== allocationId && !selectedByOtherRequirements.has(allocation.id),
);
};
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
@@ -149,7 +265,69 @@ export function CreateServerPage() {
</SelectContent>
</Select>
</div>
<Button onClick={() => setStep(2)} disabled={!name || !gameId}>
{visibleEnvironmentVars.map((variable) => {
const key = variable.key.trim();
const label = variable.label?.trim() || key;
const value = environment[key] ?? String(variable.default ?? '');
const isSecret =
key.toLowerCase().includes('password') || key.toLowerCase().includes('license');
return (
<div key={key} className="space-y-2">
<Label>
{label}
{variable.required ? ' *' : ''}
</Label>
{variable.inputType === 'boolean' ? (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={value === 'true' ? 'default' : 'outline'}
onClick={() =>
setEnvironment((current) => ({
...current,
[key]: 'true',
}))
}
>
{variable.enabledLabel ?? 'Enabled'}
</Button>
<Button
type="button"
variant={value === 'false' ? 'secondary' : 'outline'}
onClick={() =>
setEnvironment((current) => ({
...current,
[key]: 'false',
}))
}
>
{variable.disabledLabel ?? 'Disabled'}
</Button>
</div>
) : (
<Input
type={isSecret ? 'password' : 'text'}
value={value}
onChange={(e) =>
setEnvironment((current) => ({
...current,
[key]: e.target.value,
}))
}
placeholder={variable.description || label}
/>
)}
{variable.description && (
<p className="text-xs text-muted-foreground">{variable.description}</p>
)}
</div>
);
})}
<Button
onClick={() => setStep(2)}
disabled={!name || !gameId || missingRequiredEnvironment}
>
Next
</Button>
</CardContent>
@@ -170,6 +348,7 @@ export function CreateServerPage() {
onValueChange={(v) => {
setNodeId(v);
setAllocationId('');
setAdditionalAllocationIds({});
}}
>
<SelectTrigger>
@@ -187,7 +366,17 @@ export function CreateServerPage() {
{nodeId && (
<div className="space-y-2">
<Label>Port Allocation</Label>
<Select value={allocationId} onValueChange={setAllocationId}>
<Select
value={allocationId}
onValueChange={(value) => {
setAllocationId(value);
setAdditionalAllocationIds((current) =>
Object.fromEntries(
Object.entries(current).filter(([, selectedId]) => selectedId !== value),
),
);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a port" />
</SelectTrigger>
@@ -204,11 +393,53 @@ export function CreateServerPage() {
)}
</div>
)}
{nodeId &&
additionalPortRequirements.map((requirement) => {
const options = allocationOptionsForRequirement(requirement.key);
const protocols = requirement.protocols.join('/').toUpperCase();
return (
<div key={requirement.key} className="space-y-2">
<Label>
{requirement.label} ({protocols})
</Label>
<Select
value={additionalAllocationIds[requirement.key] ?? ''}
onValueChange={(value) =>
setAdditionalAllocationIds((current) => ({
...current,
[requirement.key]: value,
}))
}
>
<SelectTrigger>
<SelectValue placeholder={`Select port ${requirement.defaultPort}`} />
</SelectTrigger>
<SelectContent>
{options.map((allocation) => (
<SelectItem key={allocation.id} value={allocation.id}>
{allocation.ip}:{allocation.port}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{requirement.description}</p>
{options.length === 0 && (
<p className="text-sm text-destructive">
No free allocation available for this port
</p>
)}
</div>
);
})}
<div className="flex gap-2">
<Button variant="outline" onClick={() => setStep(1)}>
Back
</Button>
<Button onClick={() => setStep(3)} disabled={!nodeId || !allocationId}>
<Button
onClick={() => setStep(3)}
disabled={!nodeId || !allocationId || missingRequiredAdditionalPorts}
>
Next
</Button>
</div>
@@ -0,0 +1,82 @@
=== CONDUIT CRASH REPORT ==========================================
Framework : Conduit 0.1.0-dev
Time : 2026-06-13 11:34:58 (local)
Process : pid=36
Signal : 11 (Segmentation fault), fault address (nil)
--- ATTRIBUTION ---------------------------------------------------
1. owner=crash_test_plugin callsite=conduit_crash_test
>>> Most likely culprit: 'crash_test_plugin' (in 'conduit_crash_test')
--- STACK TRACE ---------------------------------------------------
#00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbfb64) [0x7fdf9e694b64]
#01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050]
#02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf66c) [0x7fdf9e69466c]
#03 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902]
--- RECENT EVENTS (oldest first) ----------------------------------
[-1767.736s] [INFO] perf: heartbeat: 1921 frames, avg 2.22 ms, max 20.85 ms (last 30 s)
[-1737.735s] [INFO] perf: heartbeat: 1920 frames, avg 2.16 ms, max 5.31 ms (last 30 s)
[-1707.720s] [INFO] perf: heartbeat: 1921 frames, avg 2.12 ms, max 4.39 ms (last 30 s)
[-1677.719s] [INFO] perf: heartbeat: 1920 frames, avg 2.20 ms, max 5.59 ms (last 30 s)
[-1647.706s] [INFO] perf: heartbeat: 1921 frames, avg 2.39 ms, max 23.56 ms (last 30 s)
[-1617.705s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 7.46 ms (last 30 s)
[-1587.691s] [INFO] perf: heartbeat: 1921 frames, avg 2.37 ms, max 8.58 ms (last 30 s)
[-1557.680s] [INFO] perf: heartbeat: 1921 frames, avg 2.33 ms, max 4.84 ms (last 30 s)
[-1527.679s] [INFO] perf: heartbeat: 1920 frames, avg 2.25 ms, max 5.09 ms (last 30 s)
[-1497.674s] [INFO] perf: heartbeat: 1920 frames, avg 2.18 ms, max 19.43 ms (last 30 s)
[-1467.673s] [INFO] perf: heartbeat: 1920 frames, avg 2.30 ms, max 6.87 ms (last 30 s)
[-1437.669s] [INFO] perf: heartbeat: 1920 frames, avg 2.23 ms, max 34.46 ms (last 30 s)
[-1407.668s] [INFO] perf: heartbeat: 1920 frames, avg 2.45 ms, max 6.17 ms (last 30 s)
[-1377.653s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.25 ms (last 30 s)
[-1347.653s] [INFO] perf: heartbeat: 1920 frames, avg 2.53 ms, max 5.44 ms (last 30 s)
[-1317.644s] [INFO] perf: heartbeat: 1921 frames, avg 2.51 ms, max 20.77 ms (last 30 s)
[-1287.631s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.45 ms (last 30 s)
[-1257.614s] [INFO] perf: heartbeat: 1921 frames, avg 2.57 ms, max 5.90 ms (last 30 s)
[-1227.600s] [INFO] perf: heartbeat: 1921 frames, avg 2.54 ms, max 5.79 ms (last 30 s)
[-1197.583s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 6.94 ms (last 30 s)
[-1167.578s] [INFO] perf: heartbeat: 1920 frames, avg 2.46 ms, max 20.36 ms (last 30 s)
[-1137.562s] [INFO] perf: heartbeat: 1921 frames, avg 2.47 ms, max 7.86 ms (last 30 s)
[-1107.548s] [INFO] perf: heartbeat: 1921 frames, avg 2.53 ms, max 6.38 ms (last 30 s)
[-1077.547s] [INFO] perf: heartbeat: 1920 frames, avg 2.54 ms, max 4.09 ms (last 30 s)
[-1047.543s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 33.69 ms (last 30 s)
[-1017.532s] [INFO] perf: heartbeat: 1921 frames, avg 2.44 ms, max 5.97 ms (last 30 s)
[- 987.516s] [INFO] perf: heartbeat: 1921 frames, avg 2.59 ms, max 5.71 ms (last 30 s)
[- 957.501s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 7.43 ms (last 30 s)
[- 927.501s] [INFO] perf: heartbeat: 1920 frames, avg 2.60 ms, max 9.17 ms (last 30 s)
[- 897.490s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 24.84 ms (last 30 s)
[- 867.475s] [INFO] perf: heartbeat: 1921 frames, avg 2.58 ms, max 6.20 ms (last 30 s)
[- 837.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 5.57 ms (last 30 s)
[- 807.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 6.15 ms (last 30 s)
[- 777.460s] [INFO] perf: heartbeat: 1921 frames, avg 2.70 ms, max 5.47 ms (last 30 s)
[- 747.449s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 24.22 ms (last 30 s)
[- 717.434s] [INFO] perf: heartbeat: 1921 frames, avg 2.80 ms, max 8.64 ms (last 30 s)
[- 687.419s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.28 ms (last 30 s)
[- 657.418s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 6.34 ms (last 30 s)
[- 627.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 31.89 ms (last 30 s)
[- 597.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 6.72 ms (last 30 s)
[- 567.400s] [INFO] perf: heartbeat: 1921 frames, avg 2.67 ms, max 6.53 ms (last 30 s)
[- 537.384s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.66 ms (last 30 s)
[- 507.369s] [INFO] perf: heartbeat: 1921 frames, avg 2.55 ms, max 5.30 ms (last 30 s)
[- 477.365s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 34.05 ms (last 30 s)
[- 447.364s] [INFO] perf: heartbeat: 1920 frames, avg 2.59 ms, max 5.42 ms (last 30 s)
[- 417.349s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.55 ms (last 30 s)
[- 387.334s] [INFO] perf: heartbeat: 1921 frames, avg 2.34 ms, max 4.63 ms (last 30 s)
[- 357.334s] [INFO] perf: heartbeat: 1920 frames, avg 2.77 ms, max 6.65 ms (last 30 s)
[- 327.332s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 26.14 ms (last 30 s)
[- 312.170s] detour 'detour_self_test' installed at 0x7fdf9e693cd0
[- 312.170s] [INFO] hooks: detour 'detour_self_test' installed at 0x7fdf9e693cd0
[- 312.169s] detour 'detour_self_test' removed
[- 312.169s] [INFO] hooks: detour 'detour_self_test' removed
[- 297.318s] [INFO] perf: heartbeat: 1921 frames, avg 2.73 ms, max 6.96 ms (last 30 s)
[- 267.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.58 ms, max 6.68 ms (last 30 s)
[- 237.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 7.08 ms (last 30 s)
[- 207.296s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 25.68 ms (last 30 s)
[- 177.293s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 5.12 ms (last 30 s)
[- 147.280s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.80 ms (last 30 s)
[- 117.280s] [INFO] perf: heartbeat: 1920 frames, avg 2.64 ms, max 6.24 ms (last 30 s)
[- 87.265s] [INFO] perf: heartbeat: 1921 frames, avg 2.78 ms, max 5.95 ms (last 30 s)
[- 57.263s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 31.12 ms (last 30 s)
[- 27.248s] [INFO] perf: heartbeat: 1921 frames, avg 2.72 ms, max 7.34 ms (last 30 s)
[- 0.001s] [WARN] core: conduit_crash_test invoked — crashing deliberately
===================================================================
@@ -0,0 +1,374 @@
# Conduit Linux Accumulated Re-run - 2026-06-15
## Scope
- Pulled all accumulated commits from `origin/main`.
- Inspected the changed files and current `docs/linux-bringup.md`.
- Rebuilt Conduit on Linux with GCC 12.
- Deployed to CS2 server `Test Sunucusu`.
- Verified the new timer, chat interception, usermessage/chat-send, and console adopt paths.
- Cleaned temporary test subscriptions/timers/commands.
- Satisfactory server was not modified.
## Git
Previous local HEAD before this run:
```text
bb58461 Phase 2: dynamic console commands + convar access
```
Pulled commits:
```text
43855ff PLAN: chat ships plain (white) via UTIL_SayTextFilter; color is a follow-up
8afbeec chat: send via UTIL_SayTextFilter again (plain text works; SayText2 shows nothing)
faded69 docs: refresh README (Phase 2 status + what works) and PLAN chat-send entry
18e7970 chat colors: prefer UTIL_SayText2Filter (renders color codes)
1743147 PLAN: chat send visually verified on Windows (msgType 0 = chat); colors pending
4b3d322 PLAN: correct the chat-send entry (native UTIL_SayTextFilter, SDK drift finding)
4ba6019 chat sending: call native UTIL_SayTextFilter instead of building the usermessage
a4af862 Phase 2: usermessages + chat sending (SayText2)
6985830 Phase 2: chat interception (say / say_team)
a375e05 Phase 2: game-thread timers driven by GameFrame
62e4ca0 Close out Linux verification of the Commands & ConVars slice
15cc25e console: track adopted convars under their owner
```
Diff summary:
```text
PLAN.md | 8 +-
README.md | 17 +++-
core/CMakeLists.txt | 3 +
core/src/conduit/chat.cpp | 165 ++++++++++++++++++++++++++++++++++++++
core/src/conduit/chat.h | 46 +++++++++++
core/src/conduit/commands.cpp | 131 ++++++++++++++++++++++++++++++
core/src/conduit/console.cpp | 23 +++++-
core/src/conduit/timers.cpp | 181 ++++++++++++++++++++++++++++++++++++++++++
core/src/conduit/timers.h | 47 +++++++++++
core/src/conduit/usermsg.cpp | 147 ++++++++++++++++++++++++++++++++++
core/src/conduit/usermsg.h | 23 ++++++
core/src/plugin.cpp | 16 ++++
docs/linux-bringup.md | 76 +++++++++++++++---
gamedata/core.json | 15 +++-
```
Working tree after the run:
```text
## main...origin/main
```
## Build
Command:
```sh
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_C_COMPILER=gcc-12 \
-DCMAKE_CXX_COMPILER=g++-12
cmake --build /root/codex/Conduit/build/linux-rerun
```
Result: build and link succeeded.
```text
-- Configuring done
-- Generating done
-- Build files have been written to: /root/codex/Conduit/build/linux-rerun
[1/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/timers.cpp.o
[2/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o
[3/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/usermsg.cpp.o
[4/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/console.cpp.o
[5/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/chat.cpp.o
[6/7] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o
[7/7] Linking CXX shared library package/addons/conduit/bin/conduit.so
```
Only warning observed:
```text
/root/codex/Conduit/core/src/conduit/commands.cpp:521:13: warning: compound assignment with volatile-qualified left operand is deprecated [-Wvolatile]
```
No failed CMake/Ninja output exists because the build passed.
## Deploy And Load
Deployed:
```text
/root/codex/Conduit/build/linux-rerun/package/.
```
To:
```text
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
```
Load checks:
```text
--- meta list ---
Listing 1 plugin:
[01] Conduit (0.1.0-dev) by Conduit Contributors
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 73.6 s
game frames : 3983
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260615.log
crash report: armed (use conduit_crash_test to verify)
```
Gamedata and usermessage function resolution:
```text
--- conduit_gamedata ---
gamedata: 1 files, 2 signatures, 0 offsets
[ok] UTIL_SayText2Filter server 0x7fd2de250b30 (core.json)
[ok] UTIL_SayTextFilter server 0x7fd2de2508d0 (core.json)
--- conduit_usermsg ---
[Conduit] [INFO] usermsg: chat sending live via UTIL_SayTextFilter (0x7fd2de2508d0) - plain text, no color yet
usermessages: chat sending live (SayText2 resolved, SayText resolved)
```
Startup log:
```text
2026-06-15 10:50:26 [INFO] gamedata: 1 files loaded: 2 signatures, 0 offsets, all valid
2026-06-15 10:50:27 [INFO] entity: armed via CGameEntitySystem vtable 0x7fd2dec4ffd8
2026-06-15 10:50:27 [INFO] gameevents: armed via CGameEventManager vtable 0x7fd2dec50f50
2026-06-15 10:50:27 [INFO] chat: ready - hook installs on first subscriber
2026-06-15 10:50:27 [INFO] core: Conduit 0.1.0-dev loaded - crash reporter armed, profiler on
2026-06-15 10:50:27 [INFO] gameevents: game event manager connected (0x7fd2defd0460)
2026-06-15 10:50:37 [INFO] core: first GameFrame observed - hook dispatch confirmed
```
Entity system still resolves on Linux at `GameResourceServiceServerV001+0x50`:
```text
--- conduit_entity ---
[Conduit] [INFO] entity: entity system connected (0x7fd2da446000) via GameResourceServiceServerV001+0x50 - vtable matches CGameEntitySystem
entity system: connected
slot 0 cs_player_controller team=2 pawn=player health=100
slot 1 cs_player_controller team=3 pawn=player health=100
```
## Timer Tests
Schedule/list:
```text
--- conduit_timer_after 2 ---
[Conduit] one-shot timer #1 scheduled in 2.00 s
--- conduit_timer_every 1 ---
[Conduit] repeating timer #2 every 1.00 s (stops after 5)
--- conduit_timers ---
timers: 2 active
#1 owner=timer_test once next in 1938 ms
#2 owner=timer_test repeat next in 969 ms
```
Log block:
```text
2026-06-15 10:51:55 [INFO] timer_test: repeat timer tick 1
2026-06-15 10:51:56 [INFO] timer_test: one-shot timer fired
2026-06-15 10:51:56 [INFO] timer_test: repeat timer tick 2
2026-06-15 10:51:57 [INFO] timer_test: repeat timer tick 3
2026-06-15 10:51:58 [INFO] timer_test: repeat timer tick 4
2026-06-15 10:51:59 [INFO] timer_test: repeat timer tick 5
2026-06-15 10:51:59 [INFO] timer_test: repeat timer self-cancelled after 5 ticks
```
After firing:
```text
--- conduit_timers ---
timers: 0 active
--- conduit_prof ---
timer_test timer 6 71.5us 131.1us 131.1us 101.4us 429.0us
```
Group cancel:
```text
--- conduit_timer_every 1 ---
[Conduit] repeating timer #3 every 1.00 s (stops after 5)
--- conduit_timer_after 30 ---
[Conduit] one-shot timer #4 scheduled in 30.00 s
--- conduit_timers ---
timers: 2 active
#3 owner=timer_test repeat next in 937 ms
#4 owner=timer_test once next in 29969 ms
--- conduit_timer_stop ---
[Conduit] cancelled 2 test timer(s)
--- conduit_timers ---
timers: 0 active
```
Result: timers passed, including self-cancel from inside callback and owner group cancel.
## Chat Interception Tests
RCON output:
```text
--- conduit_chat ---
chat: 0 subscriber(s), interception hook off
--- conduit_chat_listen ---
[Conduit] [INFO] chat: 'chat_test' subscribed to chat
[Conduit] listening to chat
--- conduit_chat ---
chat: 1 subscriber(s), interception hook on
owner=chat_test hits=0
--- say "hello from server" ---
[Conduit] [INFO] chat_test: say slot=-1: "hello from server"
[All Chat][Console (0)]: "hello from server"
L 06/15/2026 - 10:53:03: "Console<0>" say ""hello from server""
--- say "this has badword inside" ---
[Conduit] [INFO] chat_test: say slot=-1: "this has badword inside" [SUPPRESSED]
--- say_team "x" ---
[Conduit] [INFO] chat_test: say_team slot=-1: "x"
--- conduit_prof ---
chat_test chat 3 97.0us 131.1us 131.1us 127.4us 290.9us
```
Conduit log:
```text
2026-06-15 10:53:03 [INFO] chat: 'chat_test' subscribed to chat
2026-06-15 10:53:03 [INFO] chat_test: say slot=-1: "hello from server"
2026-06-15 10:53:03 [INFO] chat_test: say slot=-1: "this has badword inside" [SUPPRESSED]
2026-06-15 10:53:03 [INFO] chat_test: say_team slot=-1: "x"
```
The suppressed `badword` message did not echo as `[All Chat]` in the RCON output.
Cleanup:
```text
--- conduit_chat_stop ---
[Conduit] removed 1 chat subscriber(s)
--- conduit_chat ---
chat: 0 subscriber(s), interception hook off
--- say "after stop visible" ---
[All Chat][Console (0)]: "after stop visible"
L 06/15/2026 - 10:53:20: "Console<0>" say ""after stop visible""
--- conduit_chat ---
chat: 0 subscriber(s), interception hook off
```
Result: chat interception, suppression, say_team distinction, profiler scope, and hook removal all passed.
## Chat Sending / UserMessages
Commands:
```text
--- conduit_usermsg ---
usermessages: chat sending live (SayText2 resolved, SayText resolved)
--- conduit_say linux broadcast test ---
[Conduit] PrintToAll sent
--- conduit_say_player 0 private_ping ---
[Conduit] PrintToPlayer(0) sent
--- conduit_usermsg ---
usermessages: chat sending live (SayText2 resolved, SayText resolved)
```
Result: Linux signatures resolve and native send calls return `sent`. This verifies the server-side path and no crash. I cannot visually confirm client rendering from this environment; per latest plan this path sends plain white text through `UTIL_SayTextFilter`.
## Console Adopt Fix
This re-tested the previously observed adopted-convar listing issue.
```text
--- conduit_console ---
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
[Conduit] [INFO] console: convar 'conduit_demo_value' created (owner 'console_demo')
console: ready, 1 command(s), 1 plugin convar(s)
cmd conduit_demo owner=console_demo
cvar conduit_demo_value owner=console_demo value=7
--- conduit_cvar conduit_demo_value 42 ---
[Conduit] set 'conduit_demo_value' = '42'
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
--- conduit_concmd_stop ---
[Conduit] removed 2 demo registration(s)
--- conduit_console ---
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
[Conduit] [INFO] console: convar 'conduit_demo_value' already registered - adopting (owner 'console_demo')
console: ready, 1 command(s), 1 plugin convar(s)
cmd conduit_demo owner=console_demo
cvar conduit_demo_value owner=console_demo value=42
```
Final cleanup:
```text
--- conduit_concmd_stop ---
[Conduit] removed 2 demo registration(s)
--- conduit_demo x ---
```
Result: adopt tracking is fixed. Adopted convar now appears under `1 plugin convar(s)` and owner cleanup removes 2 registrations.
## Final Runtime State
```text
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 248.6 s
game frames : 15184
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260615.log
crash report: armed (use conduit_crash_test to verify)
--- conduit_timers ---
timers: 0 active
--- conduit_chat ---
chat: 0 subscriber(s), interception hook off
--- conduit_events ---
game events: manager connected, 0 subscription(s), 0 event(s) registered, FireEvent detour off
--- conduit_usermsg ---
usermessages: chat sending live (SayText2 resolved, SayText resolved)
```
Final profiler:
```text
engine GameFrame 13848 2.77ms 4.19ms 8.39ms 39.34ms 38.34s
timer_test timer 6 71.5us 131.1us 131.1us 101.4us 429.0us
chat_test chat 3 97.0us 131.1us 131.1us 127.4us 290.9us
core SchemaFindField 3 13.2us 8.2us 32.8us 31.7us 39.5us
```
## Final Server State
Docker:
```text
/gp_c2b47a90 running running=true exit=0 oom=false
/gp_d8f13411 exited running=false exit=130 oom=false
```
Panel DB:
```text
uuid | name | status | slug | port
----------+----------------+---------+--------------+-------
c2b47a90 | Test Sunucusu | running | cs2 | 27015
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
(2 rows)
```
## Notes
- No Conduit/Metamod/signal-specific load failure or crash was observed.
- Server console contained ordinary CS2/Steam/map warnings and warmup long-frame messages.
- No watchdog stall block appeared during this run.
- CS2 panel status was `stopped` after Docker restart; I updated only the CS2 row back to `running`. Satisfactory remained `stopped`.
@@ -0,0 +1,334 @@
# Conduit Linux Bring-up Report - 2026-06-13
Target server:
- CS2 container: `gp_c2b47a90`
- CS2 server UUID: `c2b47a90`
- CS2 name: `Test Sunucusu`
- Satisfactory container: `gp_d8f13411` (left untouched)
Artifacts copied next to this report:
- `conduit-20260613.log`
- `conduit-crash-20260613-113458.txt`
Final service state after crash test recovery:
```text
/gp_c2b47a90 running running=true exit=0 oom=false
/gp_d8f13411 exited running=false exit=130 oom=false
```
Panel DB state after recovery:
```text
uuid | name | status | slug | port
----------+----------------+---------+--------------+-------
c2b47a90 | Test Sunucusu | running | cs2 | 27015
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
(2 rows)
```
## 1. Build Result
Final build succeeded using GCC 12 in `build/linux-gcc12`.
Final link output:
```text
[27/27] Linking CXX shared library package/addons/conduit/bin/conduit.so
```
Build/deploy issues encountered and fixed:
- Ubuntu `clang++` was 14.0.0 and rejected `-std=c++23`.
- `hl2sdk` headers needed POSIX aliases for `stricmp`/`strcmpi` during this Linux build.
- `crash_handler_linux.cpp` needed `cstdarg` visible for `va_start`/`va_end`; handled via build flag.
- `watchdog.cpp` had a Linux compile error because glibc `SIGRTMIN` is runtime, not constexpr. Local source change:
```diff
-constexpr int kSampleSignal = SIGRTMIN + 4;
+const int kSampleSignal = SIGRTMIN + 4;
```
- First deployed build failed to load in Metamod with protobuf ABI mismatch:
```text
[META] Failed to load plugin addons/conduit/bin/conduit: /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so: undefined symbol: _ZNK6google8protobuf7Message11GetTypeNameB5cxx11Ev
```
Rebuilt with `_GLIBCXX_USE_CXX11_ABI=0`; Conduit then loaded successfully.
Final configure command used:
```sh
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-gcc12 -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_C_COMPILER=gcc-12 \
-DCMAKE_CXX_COMPILER=g++-12 \
"-DCMAKE_CXX_FLAGS=-include strings.h -include cstdarg -Dstricmp=strcasecmp -Dstrcmpi=strcasecmp -D_GLIBCXX_USE_CXX11_ABI=0"
```
## 2. meta list
```text
rcon: authenticated
--- meta list ---
Listing 1 plugin:
[01] Conduit (0.1.0-dev) by Conduit Contributors
```
## 3. conduit_status + conduit_prof
```text
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 3384.6 s
game frames : 215969
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260613.log
crash report: armed (use conduit_crash_test to verify)
--- conduit_prof ---
owner callsite calls avg p50 p99 max total
engine GameFrame 215971 2.36ms 4.19ms 4.19ms 34.46ms 510.40s
stall_test_plugin conduit_stall_test 1 1.50s 2.15s 2.15s 1.50s 1.50s
core SchemaFindField 2 98.4us 262.1us 262.1us 164.3us 196.7us
```
After the deliberate crash test and restart, Conduit loaded again:
```text
rcon: authenticated
--- meta list ---
Listing 1 plugin:
[01] Conduit (0.1.0-dev) by Conduit Contributors
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 130.2 s
game frames : 7760
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260613.log
crash report: armed (use conduit_crash_test to verify)
```
## 4. conduit_schema
```text
--- conduit_schema CCSPlayerPawn m_iHealth ---
CCSPlayerPawn::m_iHealth -> offset 0x5B0 (1456), type int32
--- conduit_schema CBaseEntity m_iTeamNum ---
CBaseEntity::m_iTeamNum -> offset 0x624 (1572), type uint8
```
Comparison with Windows reference from the bring-up doc:
- `CCSPlayerPawn::m_iHealth`: Linux `0x5B0`, Windows expected `0x2D0` -> mismatch.
- `CBaseEntity::m_iTeamNum`: Linux `0x624`, Windows expected `0x344` -> mismatch.
## 5. conduit_stall_test 1500 Watchdog Block
RCON command:
```text
rcon: authenticated
--- conduit_stall_test 1500 ---
[Conduit] stalling the game thread for 1500 ms...
```
Conduit log block:
```text
2026-06-13 10:35:31 [WARN] watchdog: game thread has not advanced a frame for 591 ms (outside GameFrame)
2026-06-13 10:35:31 [WARN] watchdog: context 1: owner=stall_test_plugin callsite=conduit_stall_test
2026-06-13 10:35:31 [WARN] watchdog: >>> most likely culprit: 'stall_test_plugin' (in 'conduit_stall_test')
2026-06-13 10:35:31 [WARN] watchdog: #00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xc95c9) [0x7fdf9e69e5c9]
2026-06-13 10:35:31 [WARN] watchdog: #01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050]
2026-06-13 10:35:31 [WARN] watchdog: #02 /lib/x86_64-linux-gnu/libc.so.6(clock_nanosleep+0x65) [0x7fdfefae4545]
2026-06-13 10:35:31 [WARN] watchdog: #03 /lib/x86_64-linux-gnu/libc.so.6(nanosleep+0x13) [0x7fdfefae8e53]
2026-06-13 10:35:31 [WARN] watchdog: #04 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf703) [0x7fdf9e694703]
2026-06-13 10:35:31 [WARN] watchdog: #05 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902]
2026-06-13 10:35:32 [INFO] watchdog: game thread recovered after a ~1586 ms stall
```
Notes:
- Attribution worked.
- Stack sample landed.
- Symbol quality is partial: `libconduit.so(+0x...)` offsets are present, but not function names such as `conduit_stall_test`.
## 6. conduit_crash_test Report
RCON command result:
```text
rcon: authenticated
--- conduit_crash_test ---
(no response: connection closed)
```
Generated file:
```text
/var/lib/gamepanel/servers/c2b47a90/game/csgo/addons/conduit/crashes/conduit-crash-20260613-113458.txt
```
Crash report content:
```text
=== CONDUIT CRASH REPORT ==========================================
Framework : Conduit 0.1.0-dev
Time : 2026-06-13 11:34:58 (local)
Process : pid=36
Signal : 11 (Segmentation fault), fault address (nil)
--- ATTRIBUTION ---------------------------------------------------
1. owner=crash_test_plugin callsite=conduit_crash_test
>>> Most likely culprit: 'crash_test_plugin' (in 'conduit_crash_test')
--- STACK TRACE ---------------------------------------------------
#00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbfb64) [0x7fdf9e694b64]
#01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7fdfefa51050]
#02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xbf66c) [0x7fdf9e69466c]
#03 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7fdfef4ca902]
--- RECENT EVENTS (oldest first) ----------------------------------
[-1767.736s] [INFO] perf: heartbeat: 1921 frames, avg 2.22 ms, max 20.85 ms (last 30 s)
[-1737.735s] [INFO] perf: heartbeat: 1920 frames, avg 2.16 ms, max 5.31 ms (last 30 s)
[-1707.720s] [INFO] perf: heartbeat: 1921 frames, avg 2.12 ms, max 4.39 ms (last 30 s)
[-1677.719s] [INFO] perf: heartbeat: 1920 frames, avg 2.20 ms, max 5.59 ms (last 30 s)
[-1647.706s] [INFO] perf: heartbeat: 1921 frames, avg 2.39 ms, max 23.56 ms (last 30 s)
[-1617.705s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 7.46 ms (last 30 s)
[-1587.691s] [INFO] perf: heartbeat: 1921 frames, avg 2.37 ms, max 8.58 ms (last 30 s)
[-1557.680s] [INFO] perf: heartbeat: 1921 frames, avg 2.33 ms, max 4.84 ms (last 30 s)
[-1527.679s] [INFO] perf: heartbeat: 1920 frames, avg 2.25 ms, max 5.09 ms (last 30 s)
[-1497.674s] [INFO] perf: heartbeat: 1920 frames, avg 2.18 ms, max 19.43 ms (last 30 s)
[-1467.673s] [INFO] perf: heartbeat: 1920 frames, avg 2.30 ms, max 6.87 ms (last 30 s)
[-1437.669s] [INFO] perf: heartbeat: 1920 frames, avg 2.23 ms, max 34.46 ms (last 30 s)
[-1407.668s] [INFO] perf: heartbeat: 1920 frames, avg 2.45 ms, max 6.17 ms (last 30 s)
[-1377.653s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.25 ms (last 30 s)
[-1347.653s] [INFO] perf: heartbeat: 1920 frames, avg 2.53 ms, max 5.44 ms (last 30 s)
[-1317.644s] [INFO] perf: heartbeat: 1921 frames, avg 2.51 ms, max 20.77 ms (last 30 s)
[-1287.631s] [INFO] perf: heartbeat: 1921 frames, avg 2.46 ms, max 6.45 ms (last 30 s)
[-1257.614s] [INFO] perf: heartbeat: 1921 frames, avg 2.57 ms, max 5.90 ms (last 30 s)
[-1227.600s] [INFO] perf: heartbeat: 1921 frames, avg 2.54 ms, max 5.79 ms (last 30 s)
[-1197.583s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 6.94 ms (last 30 s)
[-1167.578s] [INFO] perf: heartbeat: 1920 frames, avg 2.46 ms, max 20.36 ms (last 30 s)
[-1137.562s] [INFO] perf: heartbeat: 1921 frames, avg 2.47 ms, max 7.86 ms (last 30 s)
[-1107.548s] [INFO] perf: heartbeat: 1921 frames, avg 2.53 ms, max 6.38 ms (last 30 s)
[-1077.547s] [INFO] perf: heartbeat: 1920 frames, avg 2.54 ms, max 4.09 ms (last 30 s)
[-1047.543s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 33.69 ms (last 30 s)
[-1017.532s] [INFO] perf: heartbeat: 1921 frames, avg 2.44 ms, max 5.97 ms (last 30 s)
[- 987.516s] [INFO] perf: heartbeat: 1921 frames, avg 2.59 ms, max 5.71 ms (last 30 s)
[- 957.501s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 7.43 ms (last 30 s)
[- 927.501s] [INFO] perf: heartbeat: 1920 frames, avg 2.60 ms, max 9.17 ms (last 30 s)
[- 897.490s] [INFO] perf: heartbeat: 1921 frames, avg 2.56 ms, max 24.84 ms (last 30 s)
[- 867.475s] [INFO] perf: heartbeat: 1921 frames, avg 2.58 ms, max 6.20 ms (last 30 s)
[- 837.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 5.57 ms (last 30 s)
[- 807.475s] [INFO] perf: heartbeat: 1920 frames, avg 2.71 ms, max 6.15 ms (last 30 s)
[- 777.460s] [INFO] perf: heartbeat: 1921 frames, avg 2.70 ms, max 5.47 ms (last 30 s)
[- 747.449s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 24.22 ms (last 30 s)
[- 717.434s] [INFO] perf: heartbeat: 1921 frames, avg 2.80 ms, max 8.64 ms (last 30 s)
[- 687.419s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.28 ms (last 30 s)
[- 657.418s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 6.34 ms (last 30 s)
[- 627.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 31.89 ms (last 30 s)
[- 597.416s] [INFO] perf: heartbeat: 1920 frames, avg 2.61 ms, max 6.72 ms (last 30 s)
[- 567.400s] [INFO] perf: heartbeat: 1921 frames, avg 2.67 ms, max 6.53 ms (last 30 s)
[- 537.384s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.66 ms (last 30 s)
[- 507.369s] [INFO] perf: heartbeat: 1921 frames, avg 2.55 ms, max 5.30 ms (last 30 s)
[- 477.365s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 34.05 ms (last 30 s)
[- 447.364s] [INFO] perf: heartbeat: 1920 frames, avg 2.59 ms, max 5.42 ms (last 30 s)
[- 417.349s] [INFO] perf: heartbeat: 1921 frames, avg 2.66 ms, max 6.55 ms (last 30 s)
[- 387.334s] [INFO] perf: heartbeat: 1921 frames, avg 2.34 ms, max 4.63 ms (last 30 s)
[- 357.334s] [INFO] perf: heartbeat: 1920 frames, avg 2.77 ms, max 6.65 ms (last 30 s)
[- 327.332s] [INFO] perf: heartbeat: 1920 frames, avg 2.74 ms, max 26.14 ms (last 30 s)
[- 312.170s] detour 'detour_self_test' installed at 0x7fdf9e693cd0
[- 312.170s] [INFO] hooks: detour 'detour_self_test' installed at 0x7fdf9e693cd0
[- 312.169s] detour 'detour_self_test' removed
[- 312.169s] [INFO] hooks: detour 'detour_self_test' removed
[- 297.318s] [INFO] perf: heartbeat: 1921 frames, avg 2.73 ms, max 6.96 ms (last 30 s)
[- 267.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.58 ms, max 6.68 ms (last 30 s)
[- 237.308s] [INFO] perf: heartbeat: 1920 frames, avg 2.68 ms, max 7.08 ms (last 30 s)
[- 207.296s] [INFO] perf: heartbeat: 1921 frames, avg 2.50 ms, max 25.68 ms (last 30 s)
[- 177.293s] [INFO] perf: heartbeat: 1920 frames, avg 2.51 ms, max 5.12 ms (last 30 s)
[- 147.280s] [INFO] perf: heartbeat: 1921 frames, avg 2.65 ms, max 5.80 ms (last 30 s)
[- 117.280s] [INFO] perf: heartbeat: 1920 frames, avg 2.64 ms, max 6.24 ms (last 30 s)
[- 87.265s] [INFO] perf: heartbeat: 1921 frames, avg 2.78 ms, max 5.95 ms (last 30 s)
[- 57.263s] [INFO] perf: heartbeat: 1920 frames, avg 2.66 ms, max 31.12 ms (last 30 s)
[- 27.248s] [INFO] perf: heartbeat: 1921 frames, avg 2.72 ms, max 7.34 ms (last 30 s)
[- 0.001s] [WARN] core: conduit_crash_test invoked — crashing deliberately
===================================================================
```
## 7. Conduit Log File
Original log file:
```text
/var/lib/gamepanel/servers/c2b47a90/game/csgo/addons/conduit/logs/conduit-20260613.log
```
Copied artifact:
```text
/root/codex/source-gamepanel/conduit-bringup-artifacts/conduit-20260613.log
```
Relevant load lines:
```text
2026-06-13 10:33:21 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid
2026-06-13 10:33:21 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on
2026-06-13 10:33:29 [INFO] core: first GameFrame observed — hook dispatch confirmed
```
## 8. Console / Metamod / Signal Warnings
Important console findings:
1. Before the ABI rebuild, Metamod failed to load Conduit:
```text
[META] Failed to load plugin addons/conduit/bin/conduit: /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so: undefined symbol: _ZNK6google8protobuf7Message11GetTypeNameB5cxx11Ev
[META] Loaded 0 plugins.
```
This was fixed by rebuilding with `_GLIBCXX_USE_CXX11_ABI=0`.
2. After the successful build, no current Metamod load failure was observed. `meta list` reports Conduit loaded.
3. Signal/watchdog behavior:
- No signal clash symptoms observed.
- No `(stack sample timed out)` observed.
- Stack sample appeared, but symbol names are mostly raw offsets for `libconduit.so`.
4. Crash test console tail showed the expected deliberate crash:
```text
post-hook: noop
entry.sh: line 192: 36 Segmentation fault (core dumped) ./cs2 -dedicated -ip 0.0.0.0 -port 27015 -console -usercon -maxplayers 16 +game_type 0 +game_mode 1 +mapgroup mg_active +map de_dust2 +rcon_password changeme +sv_lan 0 +tv_port 27020 -insecure
```
Other engine/game warnings seen, not clearly Conduit-specific:
```text
Failed loading resource "maps/prefabs/misc/terrorist_team_intro_variant2/world_visibility.vvis_c" (ERROR_FILEOPEN: File not found)
Failed to write backup_round15.txt!
UNEXPECTED LONG FRAME DETECTED
```
## Additional Findings
`conduit_dumpbytes` found `server!CreateInterface`:
```text
[Conduit] server!CreateInterface @ 0x7fdfaa682220
55 48 89 E5 41 55 49 89 F5 41 54 53 48 83 EC 08
```
But scanning the same pattern failed:
```text
[Conduit] no match in server (285312 bytes scanned)
```
This looks like a Linux memscan bug or module range issue: export lookup can locate `CreateInterface`, but `ScanPattern` does not find the bytes at that address.
`conduit_detour_test` passed:
```text
[Conduit] detour test: PASS (before=42 during=43 after=42; expected 42/43/42)
```
@@ -0,0 +1,298 @@
# Conduit Linux Console/ConVar Re-run - 2026-06-14
## Scope
- Pulled latest Conduit commits from `origin/main`.
- Inspected the dynamic console command / convar diff and `docs/linux-bringup.md` test steps.
- Rebuilt on Linux with GCC 12.
- Deployed to CS2 server `Test Sunucusu`.
- Verified dynamic command registration, dynamic command dispatch under profiler scope, plugin convar read/write, engine convar read/write, owner cleanup, and final server state.
- Satisfactory server was not modified.
## Git
Latest commits:
```text
bb58461 (HEAD -> main, origin/main, origin/HEAD) Phase 2: dynamic console commands + convar access
a3bd08f Close out Linux verification of the Entity + Player slice
de4bda2 Phase 2: schema-backed Entity + Player access, with event bridge
4b79d33 Close out Linux verification of the Phase 2 GameEvents slice
a5c1c13 docs: Linux re-test steps for the Phase 2 GameEvents slice
ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder
```
Pulled range:
```text
Updating de4bda2..bb58461
Fast-forward
PLAN.md | 4 +-
core/CMakeLists.txt | 1 +
core/src/conduit/commands.cpp | 52 ++++++++
core/src/conduit/console.cpp | 302 ++++++++++++++++++++++++++++++++++++++++++
core/src/conduit/console.h | 78 +++++++++++
core/src/plugin.cpp | 5 +
docs/linux-bringup.md | 58 ++++++--
7 files changed, 491 insertions(+), 9 deletions(-)
create mode 100644 core/src/conduit/console.cpp
create mode 100644 core/src/conduit/console.h
```
Working tree after the run:
```text
## main...origin/main
```
## Build
Command:
```sh
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_C_COMPILER=gcc-12 \
-DCMAKE_CXX_COMPILER=g++-12
cmake --build /root/codex/Conduit/build/linux-rerun
```
Result: build and link succeeded.
```text
-- Configuring done
-- Generating done
-- Build files have been written to: /root/codex/Conduit/build/linux-rerun
[1/4] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o
[2/4] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/console.cpp.o
[3/4] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o
[4/4] Linking CXX shared library package/addons/conduit/bin/conduit.so
```
Only warning observed: the existing volatile compound-assignment warning in `commands.cpp`.
No failed CMake/Ninja output exists because the build passed. The Linux link path for `CConVar` / `ConVarRefAbstract` symbols passed.
## Deploy
Package source:
```text
/root/codex/Conduit/build/linux-rerun/package/.
```
Destination:
```text
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
```
CS2 container restarted:
```text
gp_c2b47a90
```
Satisfactory was not touched:
```text
/gp_d8f13411 exited running=false exit=130 oom=false
```
## Load Check
RCON:
```text
rcon: authenticated
--- meta list ---
Listing 1 plugin:
[01] Conduit (0.1.0-dev) by Conduit Contributors
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 47.4 s
game frames : 2251
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
crash report: armed (use conduit_crash_test to verify)
```
Startup log:
```text
2026-06-14 14:19:24 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid
2026-06-14 14:19:25 [INFO] entity: armed via CGameEntitySystem vtable 0x7f4c8640ffd8 - entity system not created yet, will resolve on first use after map load
2026-06-14 14:19:25 [INFO] gameevents: armed via CGameEventManager vtable 0x7f4c86410f50 - instance captured on next LoadEventsFromFile
2026-06-14 14:19:25 [INFO] core: Conduit 0.1.0-dev loaded - crash reporter armed, profiler on
2026-06-14 14:19:25 [INFO] gameevents: game event manager connected (0x7f4c86790460) - event listening live
2026-06-14 14:19:36 [INFO] core: first GameFrame observed - hook dispatch confirmed
```
Result: plugin loaded, frames advanced, no Conduit/Metamod load failure.
## Dynamic Command and Profiler
Commands:
```text
conduit_console
conduit_demo alpha bravo charlie
conduit_prof
```
Output:
```text
rcon: authenticated
--- conduit_console ---
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
[Conduit] [INFO] console: convar 'conduit_demo_value' created (owner 'console_demo')
console: ready, 1 command(s), 1 plugin convar(s)
cmd conduit_demo owner=console_demo
cvar conduit_demo_value owner=console_demo value=7
--- conduit_demo alpha bravo charlie ---
[Conduit] [INFO] console_demo: conduit_demo invoked: argc=4 args='alpha bravo charlie' issuer=server(-1)
--- conduit_prof ---
owner callsite calls avg p50 p99 max total
engine GameFrame 3409 2.53ms 4.19ms 8.39ms 47.48ms 8.62s
console_demo conduit_demo 1 63.1us 65.5us 65.5us 63.1us 63.1us
```
Result: dynamic command dispatch works and is profiled under `console_demo / conduit_demo`.
## ConVar Tests
Commands:
```text
conduit_cvar conduit_demo_value
conduit_cvar conduit_demo_value 42
conduit_cvar conduit_demo_value
conduit_cvar sv_cheats
conduit_cvar sv_gravity
conduit_cvar sv_gravity 700
conduit_cvar sv_gravity
```
Output:
```text
rcon: authenticated
--- conduit_cvar conduit_demo_value ---
[Conduit] conduit_demo_value = 7 (int=7 float=7.000 bool=1)
--- conduit_cvar conduit_demo_value 42 ---
[Conduit] set 'conduit_demo_value' = '42'
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
--- conduit_cvar conduit_demo_value ---
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
--- conduit_cvar sv_cheats ---
[Conduit] sv_cheats = false (int=0 float=0.000 bool=0)
--- conduit_cvar sv_gravity ---
[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1)
--- conduit_cvar sv_gravity 700 ---
L 06/14/2026 - 14:20:43: server_cvar: "sv_gravity" "700.000000"
[Conduit] set 'sv_gravity' = '700'
[Conduit] sv_gravity = 700.000000 (int=700 float=700.000 bool=1)
--- conduit_cvar sv_gravity ---
[Conduit] sv_gravity = 700.000000 (int=700 float=700.000 bool=1)
```
Result: plugin-owned convar read/write passed. Engine convar read/write passed.
`sv_gravity` was restored:
```text
rcon: authenticated
--- conduit_cvar sv_gravity 800 ---
L 06/14/2026 - 14:20:56: server_cvar: "sv_gravity" "800.000000"
[Conduit] set 'sv_gravity' = '800'
[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1)
--- conduit_cvar sv_gravity ---
[Conduit] sv_gravity = 800.000000 (int=800 float=800.000 bool=1)
```
## Owner Cleanup
Commands:
```text
conduit_concmd_stop
conduit_demo x
```
Output:
```text
--- conduit_concmd_stop ---
[Conduit] removed 2 demo registration(s)
--- conduit_demo x ---
```
Result: `conduit_demo x` produced no handler line after cleanup, so the command was removed from the engine.
I then called `conduit_console` again to probe the persistence/adopt path:
```text
--- conduit_console ---
[Conduit] [INFO] console: command 'conduit_demo' registered (owner 'console_demo')
[Conduit] [INFO] console: convar 'conduit_demo_value' already registered - adopting (owner 'console_demo')
console: ready, 1 command(s), 0 plugin convar(s)
cmd conduit_demo owner=console_demo
```
The persisted convar value remained available:
```text
--- conduit_cvar conduit_demo_value ---
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
```
Final cleanup after the re-register:
```text
rcon: authenticated
--- conduit_cvar conduit_demo_value ---
[Conduit] conduit_demo_value = 42 (int=42 float=42.000 bool=1)
--- conduit_concmd_stop ---
[Conduit] removed 1 demo registration(s)
--- conduit_demo x ---
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 115.6 s
game frames : 6615
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
crash report: armed (use conduit_crash_test to verify)
--- conduit_prof ---
owner callsite calls avg p50 p99 max total
engine GameFrame 6617 2.34ms 4.19ms 8.39ms 47.48ms 15.50s
console_demo conduit_demo 1 63.1us 65.5us 65.5us 63.1us 63.1us
```
## Finding
The adopt path works for name-based access: `conduit_demo_value` stayed at `42` and could be read after re-registration.
Potential mismatch: after adoption, `conduit_console` reported `0 plugin convar(s)` and did not list `conduit_demo_value`, despite logging `already registered - adopting`. That means the existing convar is not represented in `g_convars`, so owner cleanup removed only the command on the second cleanup (`removed 1 demo registration(s)`). If the intended behavior is "adopted convars are tracked and listed as plugin-owned", this needs a small fix. If "adopt" only means "treat existing engine convar as accessible by name", the runtime behavior is consistent.
## Final State
Docker:
```text
/gp_c2b47a90 running running=true exit=0 oom=false
/gp_d8f13411 exited running=false exit=130 oom=false
```
Panel DB:
```text
uuid | name | status | slug | port
----------+----------------+---------+--------------+-------
c2b47a90 | Test Sunucusu | running | cs2 | 27015
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
(2 rows)
```
No Conduit/Metamod/signal-specific load failure or crash was observed. Server console had ordinary CS2/Steam/map warnings only.
@@ -0,0 +1,287 @@
# Conduit Linux Entity Re-run - 2026-06-14
## Scope
- Pulled latest Conduit commits from `origin/main`.
- Inspected the Phase 2 entity/event bridge diff and updated `docs/linux-bringup.md` steps.
- Rebuilt on Linux with GCC 12.
- Deployed to CS2 server `Test Sunucusu`.
- Verified normal startup arming, lazy entity-system resolution, Linux RTTI vtable lookup, event-to-entity bridge, schema-backed health read/write, cleanup, and final server state.
- Satisfactory server was not modified.
## Git
Latest commits:
```text
de4bda2 (HEAD -> main, origin/main, origin/HEAD) Phase 2: schema-backed Entity + Player access, with event bridge
4b79d33 Close out Linux verification of the Phase 2 GameEvents slice
a5c1c13 docs: Linux re-test steps for the Phase 2 GameEvents slice
ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder
4078ab7 PLAN: close the Linux memscan question — M1 fully done on both platforms
```
Pulled range:
```text
Updating a5c1c13..de4bda2
Fast-forward
PLAN.md | 4 +-
core/CMakeLists.txt | 1 +
core/src/conduit/commands.cpp | 61 ++++++-
core/src/conduit/entity.cpp | 380 ++++++++++++++++++++++++++++++++++++++++
core/src/conduit/entity.h | 89 ++++++++++
core/src/conduit/gameevents.cpp | 9 +
core/src/conduit/gameevents.h | 9 +
core/src/conduit/memscan.cpp | 52 ++++++
core/src/conduit/memscan.h | 6 +
core/src/plugin.cpp | 5 +
docs/linux-bringup.md | 53 +++++-
```
Working tree after run:
```text
## main...origin/main
```
## Build
Command:
```sh
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_C_COMPILER=gcc-12 \
-DCMAKE_CXX_COMPILER=g++-12
cmake --build /root/codex/Conduit/build/linux-rerun
```
Result: build succeeded.
```text
-- Configuring done
-- Generating done
-- Build files have been written to: /root/codex/Conduit/build/linux-rerun
[1/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/memscan.cpp.o
[2/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/commands.cpp.o
/root/codex/Conduit/core/src/conduit/commands.cpp: In function int {anonymous}::DetourTestTarget(int):
/root/codex/Conduit/core/src/conduit/commands.cpp:338:13: warning: compound assignment with volatile-qualified left operand is deprecated [-Wvolatile]
338 | acc += x;
| ~~~~^~~~
[3/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/gamedata.cpp.o
[4/7] Building CXX object core/CMakeFiles/conduit.dir/src/plugin.cpp.o
[5/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/entity.cpp.o
[6/7] Building CXX object core/CMakeFiles/conduit.dir/src/conduit/gameevents.cpp.o
[7/7] Linking CXX shared library package/addons/conduit/bin/conduit.so
```
No failed CMake/Ninja output exists because the build passed.
## Deploy
Package source:
```text
/root/codex/Conduit/build/linux-rerun/package/.
```
Destination:
```text
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
```
CS2 container restarted:
```text
gp_c2b47a90
```
Satisfactory container state during deploy:
```text
/gp_d8f13411 exited running=false exit=130 oom=false
```
## Load Check
RCON:
```text
rcon: authenticated
--- meta list ---
Listing 1 plugin:
[01] Conduit (0.1.0-dev) by Conduit Contributors
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 45.8 s
game frames : 2220
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
crash report: armed (use conduit_crash_test to verify)
```
Startup log:
```text
2026-06-14 13:48:50 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid
2026-06-14 13:48:50 [INFO] entity: armed via CGameEntitySystem vtable 0x7f2eed759fd8 — entity system not created yet, will resolve on first use after map load
2026-06-14 13:48:51 [INFO] gameevents: armed via CGameEventManager vtable 0x7f2eed75af50 — instance captured on next LoadEventsFromFile
2026-06-14 13:48:51 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on
2026-06-14 13:48:51 [INFO] gameevents: game event manager connected (0x7f2eedada460) — event listening live
2026-06-14 13:49:01 [INFO] core: first GameFrame observed — hook dispatch confirmed
```
Result: normal startup did not crash. Entity acquisition armed at load and deferred resolution until map/entity system was available.
## Entity RTTI and Lazy Resolve
RCON:
```text
rcon: authenticated
--- conduit_vtable server CGameEntitySystem ---
[Conduit] CGameEntitySystem vtable @ 0x7f2eed759fd8 first method -> 0x7f2eec92aec0
--- conduit_entity ---
[Conduit] [INFO] entity: entity system connected (0x7f2ee9124000) via GameResourceServiceServerV001+0x50 — vtable matches CGameEntitySystem
entity system: connected
slot 0 cs_player_controller team=2 pawn=player health=100
slot 1 cs_player_controller team=3 pawn=player health=100
```
Result: Linux Itanium RTTI path found `CGameEntitySystem`. Lazy resource-service scan found the entity system at `GameResourceServiceServerV001+0x50`. This differs from Windows `+0x58`, as expected, and was found by vtable matching.
## Event to Entity Bridge
Commands:
```text
conduit_event_stop
conduit_event_listen player_spawn
mp_warmup_end
mp_restartgame 1
```
RCON / console-reported Conduit lines:
```text
[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre)
[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post)
[Conduit] listening to 'player_spawn' (pre+post) — watch the log
[Conduit] [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2
[Conduit] [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2
[Conduit] [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3
[Conduit] [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3
```
Conduit log:
```text
2026-06-14 13:50:11 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre)
2026-06-14 13:50:11 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post)
2026-06-14 13:50:11 [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2
2026-06-14 13:50:11 [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2
2026-06-14 13:50:11 [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3
2026-06-14 13:50:11 [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3
2026-06-14 13:50:12 [INFO] event_test: [pre] player_spawn userid=0 pawn=player idx=256 health=100 team=2
2026-06-14 13:50:12 [INFO] event_test: [post] player_spawn userid=0 pawn=player idx=256 health=100 team=2
2026-06-14 13:50:12 [INFO] event_test: [pre] player_spawn userid=1 pawn=player idx=260 health=100 team=3
2026-06-14 13:50:12 [INFO] event_test: [post] player_spawn userid=1 pawn=player idx=260 health=100 team=3
```
Result: event carried player reference resolved to live pawn entities in both pre and post phases. Schema-backed reads returned health/team.
## Health Read / Write
RCON:
```text
rcon: authenticated
--- conduit_ent_health 0 ---
[Conduit] slot 0 pawn=player health=100 team=2
--- conduit_ent_health 0 45 ---
[Conduit] slot 0 health 100 -> 45 (read back 45; networked write)
--- conduit_ent_health 0 ---
[Conduit] slot 0 pawn=player health=45 team=2
```
Cleanup / restore:
```text
rcon: authenticated
--- conduit_ent_health 0 100 ---
[Conduit] slot 0 health 45 -> 100 (read back 100; networked write)
--- conduit_ent_health 0 ---
[Conduit] slot 0 pawn=player health=100 team=2
--- conduit_event_stop ---
[Conduit] removed 2 test subscription(s)
--- conduit_events ---
game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off
--- conduit_prof ---
owner callsite calls avg p50 p99 max total
engine GameFrame 6851 2.44ms 4.19ms 8.39ms 33.34ms 16.74s
event_test player_spawn 8 42.6us 65.5us 131.1us 77.7us 341.2us
core SchemaFindField 3 12.7us 8.2us 32.8us 29.3us 38.0us
```
Result: schema-backed setter wrote `m_iHealth`, readback returned the written value, then health was restored to 100.
## Final State
RCON:
```text
rcon: authenticated
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 162.5 s
game frames : 9648
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
crash report: armed (use conduit_crash_test to verify)
--- conduit_events ---
game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off
```
Docker:
```text
/gp_c2b47a90 running running=true exit=0 oom=false
/gp_d8f13411 exited running=false exit=130 oom=false
```
Panel DB:
```text
uuid | name | status | slug | port
----------+----------------+---------+--------------+-------
c2b47a90 | Test Sunucusu | running | cs2 | 27015
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
(2 rows)
```
## Odd Warnings / Notes
No Conduit/Metamod/signal load failure or crash was observed.
The `conduit_vtable` / `conduit_entity` window produced one watchdog sample:
```text
2026-06-14 13:49:57 [WARN] watchdog: game thread has not advanced a frame for 521 ms (outside GameFrame)
2026-06-14 13:49:57 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path
2026-06-14 13:49:57 [WARN] watchdog: #00 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcff99) [0x7f2ee4b0ef99]
2026-06-14 13:49:57 [WARN] watchdog: #01 /lib/x86_64-linux-gnu/libc.so.6(+0x3c050) [0x7f2f31cc2050]
2026-06-14 13:49:57 [WARN] watchdog: #02 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd020) [0x7f2ee4b0c020]
2026-06-14 13:49:57 [WARN] watchdog: #03 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd160) [0x7f2ee4b0c160]
2026-06-14 13:49:57 [WARN] watchdog: #04 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd523) [0x7f2ee4b0c523]
2026-06-14 13:49:57 [WARN] watchdog: #05 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcd60f) [0x7f2ee4b0c60f]
2026-06-14 13:49:57 [WARN] watchdog: #06 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xcdecd) [0x7f2ee4b0cecd]
2026-06-14 13:49:57 [WARN] watchdog: #07 /home/steam/cs2-dedicated/game/csgo/addons/conduit/bin/conduit.so(+0xc10d5) [0x7f2ee4b000d5]
2026-06-14 13:49:57 [WARN] watchdog: #08 /home/steam/cs2-dedicated/game/bin/linuxsteamrt64/libtier0.so(+0x1e0902) [0x7f2f3173b902]
2026-06-14 13:49:57 [INFO] entity: entity system connected (0x7f2ee9124000) via GameResourceServiceServerV001+0x50 — vtable matches CGameEntitySystem
2026-06-14 13:49:57 [INFO] watchdog: game thread recovered after a ~816 ms stall
```
This looks tied to the diagnostic RTTI/entity scan command window. The server recovered and continued through later event and health tests.
Server console also printed ordinary CS2 map/resource/round warnings such as missing lightmap resources and `Failed to write backup_round00.txt!`; I did not see those tied to Conduit/Metamod/signal failure.
@@ -0,0 +1,307 @@
# Conduit Linux GameEvents Re-run - 2026-06-14
## Scope
- Pulled latest Conduit commits from `origin/main`.
- Inspected the new Phase 2 dispatch/GameEvents diff.
- Rebuilt clean on Linux with GCC 12.
- Deployed the resulting package to CS2 server `Test Sunucusu`.
- Verified Linux RTTI vtable lookup, GameEvent manager connection, pre/post dispatch, cancellation, profiler accounting, and cleanup.
- Did not touch the Satisfactory server files/container.
## Git
Latest commits after pull:
```text
a5c1c13 (HEAD -> main, origin/main, origin/HEAD) docs: Linux re-test steps for the Phase 2 GameEvents slice
ffb57f3 Phase 2 start: dispatch core + GameEvents, with an RTTI vtable finder
4078ab7 PLAN: close the Linux memscan question - M1 fully done on both platforms
```
Diff summary inspected:
```text
PLAN.md | 4 +-
core/CMakeLists.txt | 1 +
core/src/conduit/commands.cpp | 85 ++++++++++++
core/src/conduit/gameevents.cpp | 294 ++++++++++++++++++++++++++++++++++++++++
core/src/conduit/gameevents.h | 73 ++++++++++
core/src/conduit/memscan.cpp | 237 ++++++++++++++++++++++++++++++--
core/src/conduit/memscan.h | 7 +
core/src/plugin.cpp | 5 +
docs/linux-bringup.md | 24 ++++
```
Working tree after the run:
```text
## main...origin/main
```
## Build
Build command used:
```sh
cmake -S /root/codex/Conduit -B /root/codex/Conduit/build/linux-rerun -G Ninja \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_C_COMPILER=gcc-12 \
-DCMAKE_CXX_COMPILER=g++-12
cmake --build /root/codex/Conduit/build/linux-rerun
```
Result: build succeeded.
Final build line:
```text
[30/30] Linking CXX shared library package/addons/conduit/bin/conduit.so
```
Warnings only:
```text
- protobuf syntax warnings
- paths.cpp snprintf truncation warnings
- commands.cpp volatile compound assignment deprecated
- log.cpp snprintf truncation warning
- C-only warning for C++ flags on Zydis.c
```
No full cmake/ninja failure output exists because the build did not fail.
## Deploy
Deployed package:
```text
/root/codex/Conduit/build/linux-rerun/package/.
```
Destination:
```text
/var/lib/gamepanel/servers/c2b47a90/game/csgo/
```
Server restarted:
```text
gp_c2b47a90
```
Satisfactory server was not modified.
## Initial Load
`meta list` and `conduit_status` after deploy:
```text
rcon: authenticated
--- meta list ---
Listing 1 plugin:
[01] Conduit (0.1.0-dev) by Conduit Contributors
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 88.9 s
game frames : 5066
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
crash report: armed (use conduit_crash_test to verify)
```
Startup log lines:
```text
2026-06-14 11:34:50 [INFO] gamedata: 1 files loaded: 0 signatures, 0 offsets, all valid
2026-06-14 11:34:50 [INFO] gameevents: armed via CGameEventManager vtable 0x7f47c7ae2f50 — instance captured on next LoadEventsFromFile
2026-06-14 11:34:50 [INFO] core: Conduit 0.1.0-dev loaded — crash reporter armed, profiler on
2026-06-14 11:34:50 [INFO] gameevents: game event manager connected (0x7f47c7e62460) — event listening live
2026-06-14 11:35:00 [INFO] core: first GameFrame observed — hook dispatch confirmed
```
## RTTI Vtable Checks
Commands:
```text
conduit_vtable server CGameEventManager
conduit_vtable server CCSPlayerController
```
Output:
```text
rcon: authenticated
--- conduit_vtable server CGameEventManager ---
[Conduit] CGameEventManager vtable @ 0x7f47c7ae2f50 first method -> 0x7f47c6d0aa80
--- conduit_vtable server CCSPlayerController ---
[Conduit] CCSPlayerController vtable @ 0x7f47c7ace5a8 first method -> 0x7f47c6b169a0
```
Result: Linux Itanium RTTI lookup passed for both classes.
## Listen Test
Commands:
```text
conduit_event_stop
conduit_event_listen player_spawn
mp_restartgame 1
```
RCON output:
```text
rcon: authenticated
--- conduit_event_stop ---
[Conduit] removed 0 test subscription(s)
--- conduit_event_listen player_spawn ---
[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre)
[Conduit] [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post)
[Conduit] listening to 'player_spawn' (pre+post) — watch the log
--- mp_restartgame 1 ---
```
Log evidence:
```text
2026-06-14 11:55:18 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (pre)
2026-06-14 11:55:18 [INFO] gameevents: 'event_test' subscribed to 'player_spawn' (post)
2026-06-14 11:55:20 [INFO] event_test: [pre] player_spawn userid=0 attacker=-1
2026-06-14 11:55:20 [INFO] event_test: [post] player_spawn userid=0 attacker=-1
2026-06-14 11:55:20 [INFO] event_test: [pre] player_spawn userid=1 attacker=-1
2026-06-14 11:55:20 [INFO] event_test: [post] player_spawn userid=1 attacker=-1
```
`conduit_events` and `conduit_prof` after listen:
```text
rcon: authenticated
--- conduit_events ---
game events: manager connected, 2 subscription(s), 1 event(s) registered, FireEvent detour on
event_test player_spawn pre hits=2
event_test player_spawn post hits=2
--- conduit_prof ---
owner callsite calls avg p50 p99 max total
engine GameFrame 80323 2.28ms 4.19ms 4.19ms 27.49ms 183.11s
event_test player_spawn 4 38.1us 65.5us 65.5us 54.9us 152.6us
```
Result: pre and post dispatch both fired.
## Cancel Test
Commands:
```text
conduit_event_cancel player_spawn
mp_restartgame 1
```
RCON output:
```text
rcon: authenticated
--- conduit_event_cancel player_spawn ---
[Conduit] [INFO] gameevents: 'event_cancel_test' subscribed to 'player_spawn' (pre)
[Conduit] will cancel every 'player_spawn' until conduit_event_stop
--- mp_restartgame 1 ---
```
Log evidence:
```text
2026-06-14 12:02:21 [INFO] gameevents: 'event_cancel_test' subscribed to 'player_spawn' (pre)
2026-06-14 12:02:22 [INFO] event_test: [pre] player_spawn userid=0 attacker=-1
2026-06-14 12:02:22 [WARN] event_test: cancelling 'player_spawn' (pre)
2026-06-14 12:02:22 [INFO] event_test: [pre] player_spawn userid=1 attacker=-1
2026-06-14 12:02:22 [WARN] event_test: cancelling 'player_spawn' (pre)
```
There were no new `[post] player_spawn` lines after cancellation.
`conduit_events` and `conduit_prof` after cancel:
```text
rcon: authenticated
--- conduit_events ---
game events: manager connected, 3 subscription(s), 1 event(s) registered, FireEvent detour on
event_test player_spawn pre hits=4
event_test player_spawn post hits=2
event_cancel_test player_spawn pre hits=2
--- conduit_prof ---
owner callsite calls avg p50 p99 max total
engine GameFrame 107531 2.25ms 4.19ms 4.19ms 32.27ms 242.28s
event_test player_spawn 6 47.1us 65.5us 131.1us 76.9us 282.5us
event_cancel_test player_spawn 2 14.5us 16.4us 16.4us 14.9us 28.9us
```
Result: cancel path passed; post stayed at `hits=2` while pre advanced.
## Cleanup
Commands:
```text
conduit_event_stop
conduit_events
```
Output:
```text
rcon: authenticated
--- conduit_event_stop ---
[Conduit] removed 3 test subscription(s)
--- conduit_events ---
game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off
```
Final status:
```text
rcon: authenticated
--- conduit_status ---
Conduit 0.1.0-dev
uptime : 2228.6 s
game frames : 141868
log file : /home/steam/cs2-dedicated/game/csgo/addons/conduit/logs/conduit-20260614.log
crash report: armed (use conduit_crash_test to verify)
--- conduit_events ---
game events: manager connected, 0 subscription(s), 1 event(s) registered, FireEvent detour off
```
## Container and Panel State
Docker:
```text
/gp_c2b47a90 running running=true exit=0 oom=false
/gp_d8f13411 exited running=false exit=130 oom=false
```
Panel DB:
```text
uuid | name | status | slug | port
----------+----------------+---------+--------------+-------
c2b47a90 | Test Sunucusu | running | cs2 | 27015
d8f13411 | MukemmelSunucu | stopped | satisfactory | 7777
(2 rows)
```
## Odd Warnings
During the RTTI/vtable/memscan command window, the log briefly reported watchdog stalls outside Conduit context:
```text
2026-06-14 11:50:54 [WARN] watchdog: game thread has not advanced a frame for 509 ms (outside GameFrame)
2026-06-14 11:50:54 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path
2026-06-14 11:51:04 [WARN] watchdog: game thread has not advanced a frame for 585 ms (outside GameFrame)
2026-06-14 11:51:04 [WARN] watchdog: no Conduit context on the game thread — the stall is in engine/game code or an untracked path
```
The server recovered and continued through 141k+ frames. I did not see Conduit/Metamod/signal load failures or crashes in this run.
@@ -0,0 +1,66 @@
INSERT INTO "games" (
"slug",
"name",
"docker_image",
"default_port",
"config_files",
"automation_rules",
"startup_command",
"stop_command",
"environment_vars",
"created_at",
"updated_at"
)
VALUES (
'satisfactory',
'Satisfactory',
'wolveix/satisfactory-server:latest',
7777,
'[]'::jsonb,
'[]'::jsonb,
'',
'quit',
'[
{
"key": "MAXPLAYERS",
"default": "4",
"description": "Maximum player count",
"required": false
},
{
"key": "STEAMBETA",
"label": "Branch",
"default": "false",
"description": "Use the experimental branch instead of stable",
"required": false,
"inputType": "boolean",
"enabledLabel": "Experimental",
"disabledLabel": "Stable"
},
{
"key": "AUTOSAVENUM",
"default": "5",
"description": "Number of rotating autosaves",
"required": false
},
{
"key": "MAXTICKRATE",
"default": "30",
"description": "Maximum simulation tick rate",
"required": false
}
]'::jsonb,
now(),
now()
)
ON CONFLICT ("slug") DO UPDATE
SET
"name" = EXCLUDED."name",
"docker_image" = EXCLUDED."docker_image",
"default_port" = EXCLUDED."default_port",
"config_files" = EXCLUDED."config_files",
"automation_rules" = EXCLUDED."automation_rules",
"startup_command" = EXCLUDED."startup_command",
"stop_command" = EXCLUDED."stop_command",
"environment_vars" = EXCLUDED."environment_vars",
"updated_at" = now();
@@ -50,6 +50,13 @@
"when": 1772900000000,
"tag": "0006_cs2_servername_branding",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1773000000000,
"tag": "0007_satisfactory_game",
"breakpoints": true
}
]
}
+145 -6
View File
@@ -1,4 +1,5 @@
import { createDb } from './client';
import { eq } from 'drizzle-orm';
import { games } from './schema/games';
import { users } from './schema/users';
@@ -292,8 +293,18 @@ async function seed() {
description: 'Steam Workshop map id to launch',
required: false,
},
{ key: 'CS2_GAMETYPE', default: '0', description: 'Game type numeric value', required: false },
{ key: 'CS2_GAMEMODE', default: '1', description: 'Game mode numeric value', required: false },
{
key: 'CS2_GAMETYPE',
default: '0',
description: 'Game type numeric value',
required: false,
},
{
key: 'CS2_GAMEMODE',
default: '1',
description: 'Game mode numeric value',
required: false,
},
{
key: 'CS2_ADDITIONAL_ARGS',
default: '',
@@ -340,7 +351,12 @@ async function seed() {
],
environmentVars: [
{ key: 'EULA', default: 'TRUE', description: 'Accept Minecraft EULA', required: true },
{ key: 'VERSION', default: 'LATEST', description: 'Bedrock server version', required: true },
{
key: 'VERSION',
default: 'LATEST',
description: 'Bedrock server version',
required: true,
},
],
},
{
@@ -377,15 +393,138 @@ async function seed() {
stopCommand: 'quit',
configFiles: [],
environmentVars: [
{ key: 'RUST_SERVER_NAME', default: 'My Rust Server', description: 'Server name', required: true },
{ key: 'RUST_SERVER_MAXPLAYERS', default: '50', description: 'Max players', required: false },
{ key: 'RUST_SERVER_IDENTITY', default: 'default', description: 'Server identity', required: false },
{
key: 'RUST_SERVER_NAME',
default: 'My Rust Server',
description: 'Server name',
required: true,
},
{
key: 'RUST_SERVER_MAXPLAYERS',
default: '50',
description: 'Max players',
required: false,
},
{
key: 'RUST_SERVER_IDENTITY',
default: 'default',
description: 'Server identity',
required: false,
},
{ key: 'RUST_RCON_PASSWORD', default: '', description: 'RCON password', required: true },
],
},
{
slug: 'satisfactory',
name: 'Satisfactory',
dockerImage: 'wolveix/satisfactory-server:latest',
defaultPort: 7777,
startupCommand: '',
stopCommand: 'quit',
configFiles: [],
automationRules: [],
environmentVars: [
{
key: 'MAXPLAYERS',
default: '4',
description: 'Maximum player count',
required: false,
},
{
key: 'STEAMBETA',
label: 'Branch',
default: 'false',
description: 'Use the experimental branch instead of stable',
required: false,
inputType: 'boolean',
enabledLabel: 'Experimental',
disabledLabel: 'Stable',
},
{
key: 'AUTOSAVENUM',
default: '5',
description: 'Number of rotating autosaves',
required: false,
},
{
key: 'MAXTICKRATE',
default: '30',
description: 'Maximum simulation tick rate',
required: false,
},
],
},
{
slug: 'fivem',
name: 'FiveM',
dockerImage: 'spritsail/fivem:latest',
defaultPort: 30120,
startupCommand: '',
stopCommand: 'quit',
configFiles: [
{
path: 'server.cfg',
parser: 'keyvalue',
editableKeys: [
'endpoint_add_tcp',
'endpoint_add_udp',
'sv_hostname',
'sv_scriptHookAllowed',
'rcon_password',
'sv_endpointprivacy',
'sv_maxclients',
],
},
],
automationRules: [],
environmentVars: [
{
key: 'LICENSE_KEY',
default: '',
description: 'Cfx.re server license key required to start FXServer',
required: true,
},
],
},
])
.onConflictDoNothing();
await db
.update(games)
.set({
name: 'FiveM',
dockerImage: 'spritsail/fivem:latest',
defaultPort: 30120,
startupCommand: '',
stopCommand: 'quit',
configFiles: [
{
path: 'server.cfg',
parser: 'keyvalue',
editableKeys: [
'endpoint_add_tcp',
'endpoint_add_udp',
'sv_hostname',
'sv_scriptHookAllowed',
'rcon_password',
'sv_endpointprivacy',
'sv_maxclients',
],
},
],
automationRules: [],
environmentVars: [
{
key: 'LICENSE_KEY',
default: '',
description: 'Cfx.re server license key required to start FXServer',
required: true,
},
],
updatedAt: new Date(),
})
.where(eq(games.slug, 'fivem'));
console.log('Seed completed successfully!');
process.exit(0);
}
+6
View File
@@ -70,6 +70,11 @@ message CreateDatabaseRequest {
string password = 3;
}
message ImportDatabaseSqlRequest {
string database_name = 1;
string sql = 2;
}
message UpdateDatabasePasswordRequest {
string username = 1;
string password = 2;
@@ -252,6 +257,7 @@ service DaemonService {
rpc DeleteServer(ServerIdentifier) returns (Empty);
rpc ReinstallServer(ServerIdentifier) returns (Empty);
rpc CreateDatabase(CreateDatabaseRequest) returns (ManagedDatabaseCredentials);
rpc ImportDatabaseSql(ImportDatabaseSqlRequest) returns (Empty);
rpc UpdateDatabasePassword(UpdateDatabasePasswordRequest) returns (Empty);
rpc DeleteDatabase(DeleteDatabaseRequest) returns (Empty);