codex steam workshop integrarion

This commit is contained in:
Frank Harris 2026-06-05 13:31:15 -05:00
parent 3cefad183d
commit c687165132
12 changed files with 629 additions and 84 deletions

View file

@ -81,6 +81,7 @@ function exec_ogp_module() {
'url' => $fields['url'],
'path' => $fields['path'],
'workshop_item_id' => $fields['workshop_item_id'],
'workshop_app_id' => $fields['workshop_app_id'],
'target_path_template' => $fields['target_path_template'],
'post_script' => $fields['post_script'],
'config_edit_rule' => $fields['config_edit_rule'],

View file

@ -25,13 +25,16 @@
* (allow_user_workshop_ids, max_workshop_ids, required_workshop_ids,
* blocked_workshop_ids); add content_id column to
* server_content_workshop so user installs link to their template
* 7 add Phase 1 Workshop runtime tracking columns to
* server_content_workshop (install_path, install_strategy, enabled,
* load_order)
*
*/
// Module general information
$module_title = "Server Content Manager";
$module_version = "2.4";
$db_version = 6;
$module_version = "2.5";
$db_version = 7;
$module_required = TRUE;
$module_menus = array(
array( 'subpage' => 'addons_manager', 'name' => 'Server Content Manager', 'group' => 'admin' )
@ -260,4 +263,31 @@ $install_queries[5] = array(
return true;
},
);
// ── db_version 7 : Workshop Phase 1 runtime tracking columns ────────────────
$install_queries[6] = array(
function ($db) {
$prefix = OGP_DB_PREFIX;
$table = $db->realEscapeSingle($prefix . 'server_content_workshop');
$columns = array(
'install_path' => "VARCHAR(512) NULL AFTER `title`",
'install_strategy' => "VARCHAR(64) NULL AFTER `install_path`",
'enabled' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `install_strategy`",
'load_order' => "INT NOT NULL DEFAULT 0 AFTER `enabled`",
);
foreach ($columns as $col => $definition) {
$check = $db->resultQuery(
"SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$table}'
AND COLUMN_NAME = '" . $db->realEscapeSingle($col) . "'"
);
if (empty($check)) {
if (!$db->query("ALTER TABLE `{$prefix}server_content_workshop` ADD COLUMN `{$col}` {$definition}")) {
return false;
}
}
}
return true;
},
);
?>

View file

@ -69,6 +69,19 @@ def ensure_under_home(path_value):
return target
def safe_folder_name(value, fallback):
text = str(value or '').strip()
if not text or '..' in text or '/' in text or '\\' in text or '\x00' in text:
return fallback
return text
def truthy(value):
if isinstance(value, bool):
return value
return str(value).strip().lower() in ('1', 'yes', 'true', 'on')
def resolve_steamcmd(explicit_path=''):
candidates = []
explicit_path = str(explicit_path or '').strip()
@ -104,6 +117,26 @@ def sync_copy(src, dst):
shutil.copy2(source_entry, target_entry)
def copy_bikeys(mod_path, keys_target, workshop_id):
if not os.path.isdir(mod_path):
return 0
keys_target = ensure_under_home(keys_target)
os.makedirs(keys_target, exist_ok=True)
copied = 0
for root, dirs, files in os.walk(mod_path):
for filename in files:
if not filename.lower().endswith('.bikey'):
continue
source_file = os.path.join(root, filename)
target_file = os.path.join(keys_target, filename)
shutil.copy2(source_file, target_file)
copied += 1
log(f"workshop_id={workshop_id} key={target_file}", 'Copying Key')
if copied == 0:
log(f"workshop_id={workshop_id} no .bikey files found; continuing", 'Copying Key')
return copied
try:
with open(manifest_path, 'r', encoding='utf-8') as handle:
manifest = json.load(handle)
@ -122,13 +155,17 @@ try:
server_root = ensure_under_home(extra.get('server_root') or home_root)
steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '')
post_install_script = str(extra.get('post_install_script') or '').strip()
item_details = manifest.get('item_details') or extra.get('item_details') or {}
default_install_strategy = str(manifest.get('install_strategy') or extra.get('install_strategy') or 'copy_to_mod_folder').strip()
default_download_dir = extra.get('workshop_download_dir') or os.path.join(server_root, 'steamapps', 'workshop', 'content', workshop_app_id or steam_app_id)
action_label = 'Queued' if action in ('install', 'update', 'check_updates') else action
log(f"action={action} manifest={manifest_path} steam_app_id={steam_app_id or 'n/a'} workshop_app_id={workshop_app_id or 'n/a'}", action_label)
for workshop_id in items:
folder_name = str(extra.get('optional_folder_name') or '').strip() or ('@' + workshop_id)
detail = item_details.get(workshop_id) or item_details.get(str(workshop_id)) or {}
folder_name = safe_folder_name(detail.get('folder_name') or extra.get('optional_folder_name') or '', '@' + workshop_id)
install_strategy = str(detail.get('install_strategy') or default_install_strategy).strip() or 'copy_to_mod_folder'
template_values = {
'HOME_ID': manifest.get('home_id', ''),
'SERVER_ROOT': server_root,
@ -139,11 +176,13 @@ try:
'FOLDER_NAME': folder_name,
'MOD_FOLDER': folder_name,
}
target_template = str(extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}')
target_path = str(extra.get('target_path_resolved') or '').strip()
target_template = str(detail.get('target_path_template') or extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}')
target_path = str(detail.get('target_path_resolved') or extra.get('target_path_resolved') or '').strip()
if len(items) != 1 or not target_path:
target_path = render_template(target_template, template_values)
target_path = ensure_under_home(target_path)
keys_target_path = str(detail.get('keys_target_path') or extra.get('keys_target_path') or os.path.join(server_root, 'keys'))
should_copy_keys = truthy(detail.get('copy_keys', extra.get('copy_keys', install_strategy in ('dayz_mod_folder', 'arma_mod_folder'))))
download_dir = ensure_under_home(render_template(default_download_dir, template_values))
source_path = os.path.join(download_dir, workshop_id)
@ -169,9 +208,11 @@ try:
fail(f"SteamCMD did not create the expected Workshop cache path: {source_path}")
if action != 'check_updates':
log(f"workshop_id={workshop_id} install_path={target_path}", 'Extracting/Copying')
log(f"workshop_id={workshop_id} strategy={install_strategy} install_path={target_path}", 'Extracting/Copying')
sync_copy(source_path, target_path)
log(f"workshop_id={workshop_id} final_folder_path={target_path}", 'Applying Folder Name')
if should_copy_keys:
copy_bikeys(target_path, keys_target_path, workshop_id)
if post_install_script:
log(f"workshop_id={workshop_id} cwd={server_root}", 'Running Post-install Script')
post_result = subprocess.run(['bash', '-lc', post_install_script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=server_root)

View file

@ -69,6 +69,19 @@ def ensure_under_home(path_value):
return target
def safe_folder_name(value, fallback):
text = str(value or '').strip()
if not text or '..' in text or '/' in text or '\\' in text or '\x00' in text:
return fallback
return text
def truthy(value):
if isinstance(value, bool):
return value
return str(value).strip().lower() in ('1', 'yes', 'true', 'on')
def resolve_steamcmd(explicit_path=''):
candidates = []
explicit_path = str(explicit_path or '').strip()
@ -105,6 +118,26 @@ def sync_copy(src, dst):
shutil.copy2(source_entry, target_entry)
def copy_bikeys(mod_path, keys_target, workshop_id):
if not os.path.isdir(mod_path):
return 0
keys_target = ensure_under_home(keys_target)
os.makedirs(keys_target, exist_ok=True)
copied = 0
for root, dirs, files in os.walk(mod_path):
for filename in files:
if not filename.lower().endswith('.bikey'):
continue
source_file = os.path.join(root, filename)
target_file = os.path.join(keys_target, filename)
shutil.copy2(source_file, target_file)
copied += 1
log(f"workshop_id={workshop_id} key={target_file}", 'Copying Key')
if copied == 0:
log(f"workshop_id={workshop_id} no .bikey files found; continuing", 'Copying Key')
return copied
try:
with open(manifest_path, 'r', encoding='utf-8') as handle:
manifest = json.load(handle)
@ -123,13 +156,17 @@ try:
server_root = ensure_under_home(extra.get('server_root') or home_root)
steamcmd_path = resolve_steamcmd(extra.get('steamcmd_path') or '')
post_install_script = str(extra.get('post_install_script') or '').strip()
item_details = manifest.get('item_details') or extra.get('item_details') or {}
default_install_strategy = str(manifest.get('install_strategy') or extra.get('install_strategy') or 'copy_to_mod_folder').strip()
default_download_dir = extra.get('workshop_download_dir') or os.path.join(server_root, 'steamapps', 'workshop', 'content', workshop_app_id or steam_app_id)
action_label = 'Queued' if action in ('install', 'update', 'check_updates') else action
log(f"action={action} manifest={manifest_path} steam_app_id={steam_app_id or 'n/a'} workshop_app_id={workshop_app_id or 'n/a'}", action_label)
for workshop_id in items:
folder_name = str(extra.get('optional_folder_name') or '').strip() or ('@' + workshop_id)
detail = item_details.get(workshop_id) or item_details.get(str(workshop_id)) or {}
folder_name = safe_folder_name(detail.get('folder_name') or extra.get('optional_folder_name') or '', '@' + workshop_id)
install_strategy = str(detail.get('install_strategy') or default_install_strategy).strip() or 'copy_to_mod_folder'
template_values = {
'HOME_ID': manifest.get('home_id', ''),
'SERVER_ROOT': server_root,
@ -140,11 +177,13 @@ try:
'FOLDER_NAME': folder_name,
'MOD_FOLDER': folder_name,
}
target_template = str(extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}')
target_path = str(extra.get('target_path_resolved') or '').strip()
target_template = str(detail.get('target_path_template') or extra.get('target_path_template') or '{SERVER_ROOT}/{MOD_FOLDER}')
target_path = str(detail.get('target_path_resolved') or extra.get('target_path_resolved') or '').strip()
if len(items) != 1 or not target_path:
target_path = render_template(target_template, template_values)
target_path = ensure_under_home(target_path)
keys_target_path = str(detail.get('keys_target_path') or extra.get('keys_target_path') or os.path.join(server_root, 'keys'))
should_copy_keys = truthy(detail.get('copy_keys', extra.get('copy_keys', install_strategy in ('dayz_mod_folder', 'arma_mod_folder'))))
download_dir = ensure_under_home(render_template(default_download_dir, template_values))
source_path = os.path.join(download_dir, workshop_id)
@ -170,9 +209,11 @@ try:
fail(f"SteamCMD did not create the expected Workshop cache path: {source_path}")
if action != 'check_updates':
log(f"workshop_id={workshop_id} install_path={target_path}", 'Extracting/Copying')
log(f"workshop_id={workshop_id} strategy={install_strategy} install_path={target_path}", 'Extracting/Copying')
sync_copy(source_path, target_path)
log(f"workshop_id={workshop_id} final_folder_path={target_path}", 'Applying Folder Name')
if should_copy_keys:
copy_bikeys(target_path, keys_target_path, workshop_id)
if post_install_script:
log(f"workshop_id={workshop_id} cwd={server_root}", 'Running Post-install Script')
post_result = subprocess.run(['bash', '-lc', post_install_script], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=server_root)

View file

@ -30,6 +30,10 @@ function scm_ensure_workshop_schema($db)
`workshop_app_id` VARCHAR(32) NULL,
`workshop_item_id` VARCHAR(64) NOT NULL,
`title` VARCHAR(255) NULL,
`install_path` VARCHAR(512) NULL,
`install_strategy` VARCHAR(64) NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
`load_order` INT NOT NULL DEFAULT 0,
`install_state` VARCHAR(32) NOT NULL DEFAULT 'selected',
`last_installed_at` DATETIME NULL,
`last_updated_at` DATETIME NULL,
@ -60,6 +64,25 @@ function scm_ensure_workshop_schema($db)
);
}
$workshop_columns = array(
'install_path' => "VARCHAR(512) NULL AFTER `title`",
'install_strategy' => "VARCHAR(64) NULL AFTER `install_path`",
'enabled' => "TINYINT(1) NOT NULL DEFAULT 1 AFTER `install_strategy`",
'load_order' => "INT NOT NULL DEFAULT 0 AFTER `enabled`",
);
foreach ($workshop_columns as $col => $definition) {
$escaped_col = $db->realEscapeSingle($col);
$col_check = $db->resultQuery(
"SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = '{$wk_table}'
AND COLUMN_NAME = '{$escaped_col}'"
);
if (empty($col_check)) {
$db->query("ALTER TABLE `".OGP_DB_PREFIX."server_content_workshop` ADD COLUMN `{$col}` {$definition}");
}
}
return $ok;
}
@ -98,28 +121,49 @@ function scm_get_workshop_rows($db, $home_id)
return array();
}
$rows = $db->resultQuery(
"SELECT * FROM `".OGP_DB_PREFIX."server_content_workshop` WHERE home_id=".$home_id." ORDER BY created_at DESC, workshop_item_id ASC"
"SELECT * FROM `".OGP_DB_PREFIX."server_content_workshop` WHERE home_id=".$home_id." ORDER BY load_order ASC, created_at DESC, workshop_item_id ASC"
);
return is_array($rows) ? $rows : array();
}
function scm_extract_workshop_item_id($value)
{
$value = trim((string)$value);
if ($value === '') {
return '';
}
if (preg_match('/^[0-9]{1,20}$/', $value)) {
return ltrim($value, '0') === '' ? '' : $value;
}
if (preg_match('/[?&]id=([0-9]{1,20})(?:[^0-9]|$)/i', $value, $matches)) {
return ltrim($matches[1], '0') === '' ? '' : $matches[1];
}
if (preg_match('/steamcommunity\.com\/(?:sharedfiles|workshop)\/filedetails\/\?[^ \t\r\n]*id=([0-9]{1,20})/i', $value, $matches)) {
return ltrim($matches[1], '0') === '' ? '' : $matches[1];
}
return '';
}
function scm_parse_workshop_ids($raw, &$invalid = array())
{
$invalid = array();
$ids = array();
// Accept IDs separated by commas, newlines, or a mix of both.
$normalized = str_replace(array("\r\n", "\r", "\n"), ',', (string)$raw);
$parts = explode(',', $normalized);
// Accept IDs or full Steam Workshop URLs separated by commas, whitespace,
// or newlines. Customer input is reduced to numeric IDs before it reaches
// manifests, shell commands, or install paths.
$normalized = preg_replace('/[\r\n\t ]+/', ',', (string)$raw);
$parts = explode(',', (string)$normalized);
foreach ((array)$parts as $part) {
$value = trim((string)$part);
if ($value === '') {
continue;
}
if (!preg_match('/^[0-9]+$/', $value)) {
$item_id = scm_extract_workshop_item_id($value);
if ($item_id === '') {
$invalid[] = $value;
continue;
}
$ids[$value] = $value;
$ids[$item_id] = $item_id;
}
return array_values($ids);
}
@ -472,11 +516,11 @@ function scm_validate_workshop_user_ids($raw_ids, &$message = '')
$invalid = array();
$ids = scm_parse_workshop_ids($raw_ids, $invalid);
if (!empty($invalid)) {
$message = 'Invalid Workshop IDs: ' . implode(', ', $invalid);
$message = 'Invalid Workshop item entries. Use a numeric Workshop ID or a Steam Workshop URL: ' . implode(', ', $invalid);
return false;
}
if (empty($ids)) {
$message = 'Enter at least one numeric Workshop ID.';
$message = 'Enter at least one Steam Workshop ID or Workshop URL.';
return false;
}
$message = '';
@ -604,6 +648,46 @@ function scm_build_workshop_runtime_context($db, array $home_info, $server_xml,
);
}
function scm_detect_workshop_install_strategy(array $home_info, $server_xml, array $template = array())
{
if (!empty($template['install_strategy'])) {
$strategy = trim((string)$template['install_strategy']);
if (preg_match('/^[a-z0-9_\-]+$/i', $strategy)) {
return strtolower($strategy);
}
}
foreach (array('workshop_install_strategy', 'install_strategy') as $tag) {
if (isset($server_xml->$tag)) {
$strategy = trim((string)$server_xml->$tag);
if ($strategy !== '' && preg_match('/^[a-z0-9_\-]+$/i', $strategy)) {
return strtolower($strategy);
}
}
}
$game_key = strtolower((string)(isset($home_info['game_key']) ? $home_info['game_key'] : ''));
$cfg_file = strtolower((string)(isset($home_info['home_cfg_file']) ? $home_info['home_cfg_file'] : ''));
$name = strtolower((string)(isset($home_info['game_name']) ? $home_info['game_name'] : ''));
$haystack = $game_key . ' ' . $cfg_file . ' ' . $name;
if (strpos($haystack, 'dayz') !== false) {
return 'dayz_mod_folder';
}
if (strpos($haystack, 'arma') !== false) {
return 'arma_mod_folder';
}
return 'copy_to_mod_folder';
}
function scm_workshop_should_copy_keys($server_xml, $install_strategy)
{
foreach (array('workshop_copy_keys', 'copy_workshop_keys') as $tag) {
if (isset($server_xml->$tag)) {
$value = strtolower(trim((string)$server_xml->$tag));
return in_array($value, array('1', 'yes', 'true', 'on'), true);
}
}
return in_array((string)$install_strategy, array('dayz_mod_folder', 'arma_mod_folder'), true);
}
function scm_build_placeholder_map(array $home_info, array $server_context = array(), array $overrides = array())
{
$home_id = (int)(isset($home_info['home_id']) ? $home_info['home_id'] : 0);

View file

@ -67,9 +67,92 @@ function scm_workshop_filter_existing_ids($db, $home_id, array $item_ids)
return array_values($allowed);
}
function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, $action, array $item_ids, &$error = '', array $extra_manifest = array())
function scm_workshop_get_content_template($db, $addon_id)
{
$addon_id = (int)$addon_id;
if ($addon_id <= 0) {
return array();
}
scm_ensure_phase2_schema($db);
$rows = $db->resultQuery(
"SELECT addon_id, name, workshop_app_id, target_path_template, optional_folder_name,
post_script, launch_param_additions, content_version, description
FROM `" . OGP_DB_PREFIX . "addons`
WHERE addon_id=" . $addon_id . " AND install_method='steam_workshop'
LIMIT 1"
);
return (is_array($rows) && !empty($rows)) ? $rows[0] : array();
}
function scm_workshop_build_manifest_context($db, array $home_info, $server_xml, array $item_ids, array $template = array())
{
$install_strategy = scm_detect_workshop_install_strategy($home_info, $server_xml, $template);
$copy_keys = scm_workshop_should_copy_keys($server_xml, $install_strategy);
$item_details = array();
$resolved_app_id = '';
$steam_app_id = '';
$template_payload = array(
'workshop_app_id' => isset($template['workshop_app_id']) ? (string)$template['workshop_app_id'] : '',
'target_path_template' => isset($template['target_path_template']) ? (string)$template['target_path_template'] : '',
'optional_folder_name' => isset($template['optional_folder_name']) ? (string)$template['optional_folder_name'] : '',
);
foreach ($item_ids as $item_id) {
$payload = $template_payload;
$payload['workshop_item_id'] = (string)$item_id;
// A fixed optional folder name is safe only when installing one item. For
// multi-item installs, use @<workshop_id> so items cannot overwrite each
// other by sharing the same target folder.
if (count($item_ids) !== 1) {
$payload['optional_folder_name'] = '';
}
$message = '';
$runtime = scm_build_workshop_runtime_context($db, $home_info, $server_xml, $payload, $message);
if ($runtime === false) {
$runtime = array();
}
$item_app_id = isset($runtime['workshop_app_id']) ? (string)$runtime['workshop_app_id'] : '';
if ($resolved_app_id === '' && $item_app_id !== '') {
$resolved_app_id = $item_app_id;
}
if ($steam_app_id === '' && !empty($runtime['steam_app_id'])) {
$steam_app_id = (string)$runtime['steam_app_id'];
}
$item_details[(string)$item_id] = array(
'workshop_item_id' => (string)$item_id,
'title' => '',
'folder_name' => isset($runtime['folder_name']) && $runtime['folder_name'] !== '' ? (string)$runtime['folder_name'] : '@' . $item_id,
'target_path_template' => isset($runtime['target_path_template']) ? (string)$runtime['target_path_template'] : '{SERVER_ROOT}/{MOD_FOLDER}',
'target_path_resolved' => isset($runtime['target_path_resolved']) ? (string)$runtime['target_path_resolved'] : '',
'install_strategy' => $install_strategy,
'copy_keys' => $copy_keys ? 1 : 0,
'keys_target_path' => rtrim((string)$home_info['home_path'], '/') . '/keys',
);
}
if ($resolved_app_id === '') {
$resolved_app_id = scm_extract_workshop_app_id($server_xml);
}
return array(
'workshop_app_id' => $resolved_app_id,
'steam_app_id' => $steam_app_id,
'server_root' => rtrim((string)$home_info['home_path'], '/'),
'install_strategy' => $install_strategy,
'copy_keys' => $copy_keys ? 1 : 0,
'target_path_template' => isset($template['target_path_template']) && trim((string)$template['target_path_template']) !== '' ? trim((string)$template['target_path_template']) : '{SERVER_ROOT}/{MOD_FOLDER}',
'post_install_script' => isset($template['post_script']) ? trim((string)$template['post_script']) : '',
'launch_param_additions' => isset($template['launch_param_additions']) ? trim((string)$template['launch_param_additions']) : '',
'content_template_id' => isset($template['addon_id']) ? (int)$template['addon_id'] : 0,
'content_template_name' => isset($template['name']) ? (string)$template['name'] : '',
'item_details' => $item_details,
);
}
function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml, $action, array $item_ids, &$error = '', array $extra_manifest = array(), &$result_details = array())
{
$error = '';
$result_details = array();
if (empty($item_ids)) {
$error = 'No Workshop IDs were selected for this action.';
return false;
@ -89,12 +172,18 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml,
$manifest_dir = dirname($manifest_path);
$manifest = array(
'manifest_version' => 1,
'action' => (string)$action,
'home_id' => (int)$home_info['home_id'],
'home_cfg_id' => (int)$home_info['home_cfg_id'],
'game_path' => $home_path,
'server_path' => $home_path,
'workshop_app_id' => (!empty($extra_manifest['workshop_app_id']) ? (string)$extra_manifest['workshop_app_id'] : scm_extract_workshop_app_id($server_xml)),
'steam_app_id' => !empty($extra_manifest['steam_app_id']) ? (string)$extra_manifest['steam_app_id'] : '',
'items' => array_values($item_ids),
'item_details' => !empty($extra_manifest['item_details']) && is_array($extra_manifest['item_details']) ? $extra_manifest['item_details'] : array(),
'install_strategy' => !empty($extra_manifest['install_strategy']) ? (string)$extra_manifest['install_strategy'] : '',
'target_path' => !empty($extra_manifest['target_path_template']) ? (string)$extra_manifest['target_path_template'] : '{SERVER_ROOT}/{MOD_FOLDER}',
'generated_at' => date('Y-m-d H:i:s'),
);
if (!empty($extra_manifest)) {
@ -133,6 +222,12 @@ function scm_workshop_write_manifest_and_run($db, array $home_info, $server_xml,
$error = 'Workshop script exit marker not found in output.';
return false;
}
$result_details = array(
'manifest_path' => $manifest_path,
'script_path' => $script_path,
'log_path' => clean_path($manifest_dir . (scm_is_windows_home($home_info) ? '/workshop_install_windows.log' : '/workshop_install.log')),
'output' => trim(preg_replace('/__GSP_WORKSHOP_EXIT:\d+/', '', $output)),
);
$exit_code = (int)$matches[1];
if ($exit_code !== 0) {
$error = 'Workshop script failed (exit '.$exit_code.'): '.trim($output);
@ -149,6 +244,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
$message = 'Workshop schema migration failed.';
return false;
}
scm_ensure_phase2_schema($db);
$home_id = (int)$home_info['home_id'];
$user_id = (int)$user_id;
@ -159,39 +255,26 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
return false;
}
// Resolve the workshop_app_id: prefer the admin content template, fall
// back to the game XML.
$template_workshop_app_id = '';
if ($addon_id > 0) {
$tpl = $db->resultQuery(
"SELECT workshop_app_id FROM `" . OGP_DB_PREFIX . "addons`
WHERE addon_id=" . $addon_id . " AND install_method='steam_workshop'"
);
if (is_array($tpl) && !empty($tpl[0]['workshop_app_id'])) {
$template_workshop_app_id = trim((string)$tpl[0]['workshop_app_id']);
}
}
$extra_manifest = array();
if ($template_workshop_app_id !== '') {
$extra_manifest['workshop_app_id'] = $template_workshop_app_id;
}
$template = scm_workshop_get_content_template($db, $addon_id);
if ($action === 'install_new') {
$invalid = array();
$item_ids = scm_parse_workshop_ids($raw_ids, $invalid);
if (!empty($invalid)) {
$message = 'Invalid Workshop IDs: ' . implode(', ', $invalid);
$message = 'Invalid Workshop item entries. Use a numeric Workshop ID or Steam Workshop URL: ' . implode(', ', $invalid);
return false;
}
if (empty($item_ids)) {
$message = 'Enter at least one numeric Workshop ID.';
$message = 'Enter at least one Steam Workshop ID or Workshop URL.';
return false;
}
// Determine the resolved workshop_app_id for storage (template first, then XML).
$resolved_app_id = $template_workshop_app_id !== ''
? $template_workshop_app_id
: scm_extract_workshop_app_id($server_xml);
$manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $item_ids, $template);
$resolved_app_id = isset($manifest_context['workshop_app_id']) ? (string)$manifest_context['workshop_app_id'] : '';
if ($resolved_app_id === '') {
$message = 'Workshop App ID is missing. Configure it on the Server Content template or game XML before installing Workshop items.';
return false;
}
// Check whether the content_id column exists (added in db_version 6).
$has_content_id_col = (bool)$db->resultQuery(
@ -201,19 +284,32 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
AND COLUMN_NAME = 'content_id'"
);
$next_order_rows = $db->resultQuery(
"SELECT COALESCE(MAX(load_order), 0) AS max_order FROM `".OGP_DB_PREFIX."server_content_workshop`
WHERE home_id=".$home_id
);
$next_order = (is_array($next_order_rows) && isset($next_order_rows[0]['max_order'])) ? (int)$next_order_rows[0]['max_order'] : 0;
foreach ($item_ids as $item_id) {
$item_detail = isset($manifest_context['item_details'][(string)$item_id]) ? $manifest_context['item_details'][(string)$item_id] : array();
$install_path = isset($item_detail['target_path_resolved']) ? (string)$item_detail['target_path_resolved'] : '';
$install_strategy = isset($item_detail['install_strategy']) ? (string)$item_detail['install_strategy'] : (string)$manifest_context['install_strategy'];
$next_order++;
$content_id_col = $has_content_id_col && $addon_id > 0 ? ", content_id" : '';
$content_id_val = $has_content_id_col && $addon_id > 0 ? ", " . $addon_id : '';
$content_id_upd = $has_content_id_col && $addon_id > 0 ? ", content_id=VALUES(content_id)" : '';
$query = "INSERT INTO `".OGP_DB_PREFIX."server_content_workshop`
(home_id, home_cfg_id, remote_server_id, workshop_app_id, workshop_item_id, install_state, created_by, created_at, updated_at" . $content_id_col . ")
(home_id, home_cfg_id, remote_server_id, workshop_app_id, workshop_item_id, install_path, install_strategy, enabled, load_order, install_state, created_by, created_at, updated_at" . $content_id_col . ")
VALUES (
".$home_id.",
".(int)$home_info['home_cfg_id'].",
".(int)$home_info['remote_server_id'].",
'".$db->realEscapeSingle($resolved_app_id)."',
'".$db->realEscapeSingle($item_id)."',
'selected',
'".$db->realEscapeSingle($install_path)."',
'".$db->realEscapeSingle($install_strategy)."',
1,
".$next_order.",
'queued',
".$user_id.",
NOW(),
NOW()
@ -223,7 +319,10 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
home_cfg_id=VALUES(home_cfg_id),
remote_server_id=VALUES(remote_server_id),
workshop_app_id=VALUES(workshop_app_id),
install_state='selected',
install_path=VALUES(install_path),
install_strategy=VALUES(install_strategy),
enabled=1,
install_state='queued',
last_error=NULL,
updated_at=NOW()" . $content_id_upd;
$db->query($query);
@ -231,12 +330,13 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installing', null, false, false);
$error = '';
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', $item_ids, $error, $extra_manifest);
$details = array();
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'install', $item_ids, $error, $manifest_context, $details);
if ($ok) {
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, true, true);
scm_workshop_log_action($db, $home_id, $user_id, "install_new ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success");
$is_error = false;
$message = 'Workshop IDs installed successfully.';
$message = 'Workshop item(s) installed successfully. Manifest: '.scm_h(isset($details['manifest_path']) ? $details['manifest_path'] : '').' Log: '.scm_h(isset($details['log_path']) ? $details['log_path'] : '');
return true;
}
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false);
@ -252,9 +352,11 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
return false;
}
$target_action = ($action === 'remove_selected') ? 'remove' : 'update';
$manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $item_ids, $template);
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installing', null, false, false);
$error = '';
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, $target_action, $item_ids, $error, $extra_manifest);
$details = array();
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, $target_action, $item_ids, $error, $manifest_context, $details);
if ($ok) {
if ($target_action === 'remove') {
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'removed', null, false, true);
@ -263,7 +365,7 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
}
scm_workshop_log_action($db, $home_id, $user_id, $action." ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success");
$is_error = false;
$message = ($target_action === 'remove') ? 'Selected Workshop IDs marked removed.' : 'Selected Workshop IDs updated successfully.';
$message = (($target_action === 'remove') ? 'Selected Workshop item(s) removed.' : 'Selected Workshop item(s) updated successfully.') . ' Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : '');
return true;
}
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false);
@ -288,14 +390,16 @@ function scm_workshop_handle_action($db, array $home_info, $user_id, $action, $r
$message = 'No Workshop IDs are currently saved for this server.';
return false;
}
$manifest_context = scm_workshop_build_manifest_context($db, $home_info, $server_xml, $item_ids, $template);
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installing', null, false, false);
$error = '';
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'update', $item_ids, $error, $extra_manifest);
$details = array();
$ok = scm_workshop_write_manifest_and_run($db, $home_info, $server_xml, 'update', $item_ids, $error, $manifest_context, $details);
if ($ok) {
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'installed', null, false, true);
scm_workshop_log_action($db, $home_id, $user_id, "update_all ids=".implode(',', $item_ids)." addon_id=".$addon_id." status=success");
$is_error = false;
$message = 'All saved Workshop IDs updated successfully.';
$message = 'All saved Workshop item(s) updated successfully. Log: ' . scm_h(isset($details['log_path']) ? $details['log_path'] : '');
return true;
}
scm_workshop_update_rows_state($db, $home_id, $item_ids, 'failed', $error, false, false);

View file

@ -3,7 +3,7 @@
*
* GSP - Server Content Workshop page
*
* Users enter Steam Workshop IDs to install on their server.
* Users enter Steam Workshop IDs or URLs to install on their server.
* The admin defines the content template (game, app ID, install path).
*
*/
@ -38,6 +38,7 @@ function exec_ogp_module() {
print_failure('Failed to initialize Workshop Content storage.');
return;
}
scm_ensure_phase2_schema($db);
// Load the admin content template if an addon_id was provided.
$addon_template = null;
@ -88,6 +89,7 @@ function exec_ogp_module() {
}
echo "</p>";
}
echo "<p class='info'>Enter a Steam Workshop URL or numeric item ID. GSP stores only the numeric Workshop ID and uses the Server Content template for game-specific install behavior.</p>";
if ($message !== '') {
if ($is_error) {
@ -114,10 +116,10 @@ function exec_ogp_module() {
<table class='center'>
<tr>
<td align='right'><strong>Workshop Item IDs</strong></td>
<td align='right'><strong>Workshop URLs / IDs</strong></td>
<td align='left'>
<textarea name='workshop_ids' rows='4' cols='72' placeholder='450814997&#10;463939057&#10;...'><?php echo scm_h($entered_ids); ?></textarea>
<br><small style="color:#666;">Enter one or more Steam Workshop IDs, one per line or comma-separated.<br>Example for Arma 3 CBA_A3: <code>450814997</code></small>
<textarea name='workshop_ids' rows='4' cols='72' placeholder='https://steamcommunity.com/sharedfiles/filedetails/?id=450814997&#10;463939057'><?php echo scm_h($entered_ids); ?></textarea>
<br><small style="color:#666;">Enter one or more Steam Workshop URLs or numeric IDs, one per line, comma-separated, or space-separated.<br>Example for Arma 3 CBA_A3: <code>https://steamcommunity.com/sharedfiles/filedetails/?id=450814997</code></small>
</td>
<td align='left' style='vertical-align:top;padding-top:4px;'>
<button type='submit' name='workshop_action' value='install_new'>Install / Queue</button>
@ -131,20 +133,26 @@ function exec_ogp_module() {
<th></th>
<th>Workshop ID</th>
<th>Title</th>
<th>Enabled</th>
<th>Order</th>
<th>State</th>
<th>Install Path</th>
<th>Last Installed</th>
<th>Last Updated</th>
<th>Last Error</th>
</tr>
<?php if (empty($rows)): ?>
<tr><td colspan='7' class='info'>No Workshop IDs saved for this server yet.</td></tr>
<tr><td colspan='10' class='info'>No Workshop items saved for this server yet.</td></tr>
<?php else: ?>
<?php foreach ((array)$rows as $row): ?>
<tr>
<td><input type='checkbox' name='selected_ids[]' value='<?php echo scm_h($row['workshop_item_id']); ?>'></td>
<td><?php echo scm_h($row['workshop_item_id']); ?></td>
<td><?php echo scm_h($row['title']); ?></td>
<td><?php echo !empty($row['enabled']) ? 'Yes' : 'No'; ?></td>
<td><?php echo scm_h(isset($row['load_order']) ? $row['load_order'] : ''); ?></td>
<td><?php echo scm_h($row['install_state']); ?></td>
<td><small><?php echo scm_h(isset($row['install_path']) ? $row['install_path'] : ''); ?></small></td>
<td><?php echo scm_h($row['last_installed_at']); ?></td>
<td><?php echo scm_h($row['last_updated_at']); ?></td>
<td><?php echo scm_h($row['last_error']); ?></td>
@ -173,4 +181,3 @@ function exec_ogp_module() {
</form>
<?php
}

View file

@ -38,23 +38,7 @@ if (!function_exists('sw_monitor_table')) {
}
}
// Only show the button when a Workshop profile is enabled for this game config.
$_sw_profile = $db->resultQuery(
"SELECT p.`id`
FROM " . sw_monitor_table('steam_workshop_game_profiles') . " p
JOIN " . sw_monitor_table('config_homes') . " c ON c.`game_key` = p.`config_name`
JOIN " . sw_monitor_table('server_homes') . " s ON s.`home_cfg_id` = c.`home_cfg_id`
WHERE s.home_id = " . (int)$server_home['home_id'] . "
AND p.enabled = 1
LIMIT 1"
);
if (!empty($_sw_profile)) {
$module_buttons[] = "<a class='monitorbutton' href='home.php?m=steam_workshop&amp;p=user&amp;home_id=" . (int)$server_home['home_id'] . "'>
<img src='" . htmlspecialchars(check_theme_image("images/steam_workshop.png"), ENT_QUOTES, 'UTF-8') . "' title='Steam Workshop'>
<span>Steam Workshop</span>
</a>";
}
unset($_sw_profile);
// Deprecated: the standalone steam_workshop workflow is no longer the primary
// user path. Server Content Manager (addonsmanager) now owns Workshop installs
// and exposes its own monitor button when content templates exist.
?>

View file

@ -2,12 +2,14 @@
## Status
Accepted
Accepted, Phase 1 implementation started
## Decision
`Panel/modules/addonsmanager` should remain the primary future home for Workshop items, mods, add-ons, and server content. `steam_workshop` should remain a deprecated compatibility layer only.
Phase 1 implements this decision by routing the user-facing Workshop install flow through `addonsmanager/workshop_content.php` and suppressing the standalone `steam_workshop` monitor button.
## Reasoning
- `addonsmanager` already has the richer schema and more complete product direction.
@ -24,3 +26,10 @@ Accepted
- `steam_workshop` is explicitly deprecated in the codebase.
- Separate modules would fragment user workflows and duplicate install logic.
## Implementation Notes
- Workshop input accepts numeric IDs or Steam URLs, then stores numeric IDs only.
- Manifests are written under the server home in `gsp_server_content`.
- Bundled Linux/Cygwin scripts are copied from the Panel module to an agent-managed folder under the server home before execution.
- DayZ/Arma-style installs default to `@<workshop_id>` folders and copy `.bikey` files into `keys` when present.
- Startup parameter generation remains a later phase.

View file

@ -0,0 +1,167 @@
# Workshop Phase 1 Implementation
## Summary
Phase 1 makes the active Server Content Manager Workshop path usable for DayZ/Arma-style Workshop installs without reviving the deprecated standalone `steam_workshop` user workflow.
Active workflow:
`Game Monitor` -> `Server Content` -> `Steam Workshop Mods`
## Files Changed
- `Panel/modules/addonsmanager/server_content_helpers.php`
- `Panel/modules/addonsmanager/workshop_action.php`
- `Panel/modules/addonsmanager/workshop_content.php`
- `Panel/modules/addonsmanager/addons_manager.php`
- `Panel/modules/addonsmanager/module.php`
- `Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh`
- `Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh`
- `Panel/modules/steam_workshop/monitor_buttons.php`
## User Flow
1. Open a server from the Panel.
2. Click `Server Content`.
3. Open the `Steam Workshop Mods` category.
4. Paste one or more Steam Workshop URLs or numeric Workshop IDs.
5. Click `Install / Queue`.
6. The Panel validates input, stores numeric Workshop IDs, writes a manifest, syncs the install script, executes it through the agent, and shows the result.
Accepted input examples:
- `450814997`
- `https://steamcommunity.com/sharedfiles/filedetails/?id=450814997`
Invalid text is rejected before reaching the manifest or shell script.
## Admin Flow
1. Create a Server Content template in `addonsmanager`.
2. Set content type to `Steam Workshop Mods`.
3. Configure `Workshop App ID` when the game XML/profile does not provide it.
4. Leave `Default Workshop IDs` blank for normal user-supplied installs.
5. Optionally configure a target path template.
The admin template defines capability and policy. The customer supplies only Workshop item IDs or URLs.
## Install Flow
1. `workshop_content.php` receives the form post.
2. `workshop_action.php` parses URLs/IDs and records rows in `server_content_workshop`.
3. The Panel builds a manifest with per-item install details.
4. The manifest is written to:
- `{SERVER_HOME}/gsp_server_content/workshop_manifest.json`
5. The correct bundled script is copied to:
- `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_linux.sh`
- or `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_windows_cygwin.sh`
6. The agent runs:
- `bash <script> <manifest>`
7. The script runs SteamCMD, copies downloaded content into the target mod folder, logs output, and copies `.bikey` files for DayZ/Arma-style strategies.
## Manifest Fields
Phase 1 manifests include:
- `manifest_version`
- `action`
- `home_id`
- `home_cfg_id`
- `game_path`
- `server_path`
- `workshop_app_id`
- `steam_app_id`
- `items`
- `item_details`
- `install_strategy`
- `target_path`
- `extra`
Each `item_details` entry includes:
- `workshop_item_id`
- `folder_name`
- `target_path_template`
- `target_path_resolved`
- `install_strategy`
- `copy_keys`
- `keys_target_path`
## DayZ / Arma Behavior
The Panel detects `dayz_mod_folder` or `arma_mod_folder` from the game key/name/config file when no explicit strategy exists.
Default install folder:
- `@<workshop_id>`
Key-copy behavior:
- `.bikey` files found anywhere in the installed mod folder are copied to `{SERVER_HOME}/keys`.
- Missing `.bikey` files are logged and do not fail the install.
## Database Tracking
`server_content_workshop` now tracks:
- `content_id`
- `install_path`
- `install_strategy`
- `enabled`
- `load_order`
- `install_state`
- `last_installed_at`
- `last_updated_at`
- `last_error`
Phase 1 states:
- `queued`
- `installing`
- `installed`
- `failed`
- `removed`
## Security Notes
- Customer input is reduced to numeric Workshop IDs only.
- Invalid text is rejected.
- Target paths are generated from trusted templates and checked to stay under the server home.
- Scripts are copied from the Panel module source into a GSP-controlled folder under the server home.
- Customers do not supply shell commands.
- Admin-defined post-install scripts remain possible but should be treated as trusted admin configuration only.
- Steam credentials are not introduced by Phase 1; scripts use anonymous login.
## Startup Parameters
Phase 1 does not rewrite startup parameter generation.
Current DayZ/Arma XML files already expose user-editable `-mod=` / `-serverMod=` parameters through `server_params`.
Phase 2 should generate structured mod lists from enabled `server_content_workshop` rows ordered by `load_order`, avoid duplicate `-mod=` entries, and preserve existing user parameters.
## Validation Performed
- PHP syntax checks passed for changed PHP files.
- Bash syntax checks passed for both bundled Workshop scripts.
- Parser test confirmed:
- numeric ID accepted
- full Steam URL accepted
- invalid text rejected
- Temporary manifest test with fake SteamCMD confirmed:
- Linux script installs into `@<workshop_id>`
- Windows/Cygwin script installs into `@<workshop_id>`
- `.bikey` files are copied to `keys`
- useful logs are written under `gsp_server_content`
## Phase 2 Work
- Add real asynchronous install jobs and progress polling.
- Resolve Workshop item titles/metadata.
- Add load-order UI.
- Wire enable/disable and load order into safe startup parameter generation.
- Support separate client mods and server-only mods.
- Add update-all and repair semantics that can preserve old installs until success.
- Add cache policy and cleanup controls.
- Add admin XML/schema fields for explicit install strategy and key-copy behavior.
- Add Steam account credential support without leaking secrets.

View file

@ -14,9 +14,72 @@ Important files:
- `Panel/modules/addonsmanager/addons_manager.php`
- `Panel/modules/addonsmanager/workshop_content.php`
- `Panel/modules/addonsmanager/workshop_action.php`
- `Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_linux.sh`
- `Panel/modules/addonsmanager/scripts/workshop/generic_steam_workshop_windows_cygwin.sh`
- `Panel/modules/steam_workshop/module.php`
- `Panel/modules/steam_workshop/agent_update_workshop.php`
## Phase 1 Implemented Behavior
The active user workflow is now `addonsmanager` -> `workshop_content`.
Users can enter either:
- a numeric Workshop item ID
- a full Steam Workshop URL containing `id=<number>`
The Panel extracts and stores only numeric Workshop IDs. Invalid text is rejected before any manifest or shell command is built.
The Panel writes a manifest under the server home:
- `{SERVER_HOME}/gsp_server_content/workshop_manifest.json`
The Panel syncs the bundled install script to:
- `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_linux.sh`
- `{SERVER_HOME}/gsp_server_content/scripts/workshop/generic_steam_workshop_windows_cygwin.sh`
The agent executes the synced script with the manifest path. Customers do not need to place scripts manually on the agent.
The manifest includes:
- `home_id`
- server/game path
- `workshop_app_id`
- Workshop item IDs
- per-item target paths
- install strategy
- key-copy settings
- content template metadata
DayZ/Arma-style installs default to `dayz_mod_folder` or `arma_mod_folder` based on the game key/name/config file. Those strategies install to `@<workshop_id>` by default and copy `.bikey` files into the server `keys` folder when found. Missing key files are logged but do not fail the install.
## Database State
`server_content_workshop` tracks:
- `content_id`
- `home_id`
- `workshop_app_id`
- `workshop_item_id`
- `title`
- `install_path`
- `install_strategy`
- `enabled`
- `load_order`
- `install_state`
- `last_installed_at`
- `last_updated_at`
- `last_error`
Current install states used by Phase 1:
- `queued`
- `installing`
- `installed`
- `failed`
- `removed`
## What Exists Today
The current direction already supports:
@ -32,10 +95,11 @@ The current direction already supports:
## Main Limitations
- Workshop metadata is still incomplete.
- load order is not yet a full first-class UX concept.
- update/uninstall/enable/disable flows need a cleaner product model.
- DayZ/Arma-specific folder and key-copy behavior needs a stronger canonical path.
- load order is tracked but not yet a full drag-and-drop or startup-param UX concept.
- enable/disable is stored but does not yet regenerate startup parameters.
- update/remove are synchronous and should become background jobs.
- caching and cleanup policy need product-level design, not just ad hoc scripts.
- `-mod=` / `-serverMod=` generation still needs a safe structured implementation.
## Recommended Mental Model
@ -50,4 +114,3 @@ Use `addonsmanager` as the main future home for:
- install history
Treat `steam_workshop` as a legacy bridge for migration only.

View file

@ -35,15 +35,30 @@ The module can already represent several content types, including:
- config packs
- future profile-type content
For Workshop items, the current flow lets users enter IDs and routes the install through module pages and agent-side scripts.
For Workshop items, the current flow lets users enter Workshop IDs or full Steam Workshop URLs and routes the install through module pages and agent-side scripts.
## Workshop Phase 1 Flow
1. Admin creates a Server Content template with install method `steam_workshop`.
2. Admin configures the Workshop App ID on the template or relies on the game XML/profile fallback.
3. User opens `Server Content` from the game monitor.
4. User selects the Steam Workshop Mods category.
5. User enters one or more Workshop URLs or numeric IDs.
6. Panel parses IDs, rejects invalid entries, and records rows in `server_content_workshop`.
7. Panel writes a manifest to `{SERVER_HOME}/gsp_server_content/workshop_manifest.json`.
8. Panel syncs the bundled Linux or Cygwin script into `{SERVER_HOME}/gsp_server_content/scripts/workshop/`.
9. Agent executes the script with the manifest path.
10. Script runs SteamCMD, copies Workshop content into the configured target path, copies DayZ/Arma `.bikey` files when applicable, and writes a log under `gsp_server_content`.
The legacy `steam_workshop` monitor button is intentionally suppressed so users are not sent to the deprecated standalone module.
## Current Limitations
- Workshop and content metadata is still partial.
- Load order and enable/disable behavior need a cleaner first-class model.
- Load order and enable/disable are tracked but not wired into startup-parameter generation yet.
- Async install job progress should be more visible.
- Install strategies are still being broadened and need consistent game-specific rules.
- DayZ/Arma style key-copy and startup-param behavior needs a stronger canonical implementation.
- DayZ/Arma style key-copy is implemented for Phase 1; startup-param behavior still needs a stronger canonical implementation.
- Cache and cleanup policy need a clearer product design.
## Where To Start Reading
@ -67,4 +82,3 @@ This module is the right place for:
- install history
The old `steam_workshop` module should be treated as a deprecated compatibility layer, not the main future path.