Last active
October 2, 2024 17:16
-
-
Save cigumo/2976b5acd5223855d1da48e20452cb57 to your computer and use it in GitHub Desktop.
Script to upload achievements to Steam
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- | |
-- Creates new achievements in steam and uploads the corresponding images. | |
-- | |
-- WARNING: Based in a non-official API used internally by Steam. Can break anytime! | |
-- REQUIRES: | |
-- - lua 5.2/5.3 or luajit 2.0 | |
-- - cURL (in path) | |
-- - json.lua https://github.com/rxi/json.lua | |
-- | |
-- Created by Ciro on 02 Sep 2018. | |
-- | |
-- Copyright 2018 Kalio Ltda. | |
-- | |
-- Permission is hereby granted, free of charge, to any person obtaining a copy of | |
-- this software and associated documentation files (the "Software"), to deal in | |
-- the Software without restriction, including without limitation the rights to | |
-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies | |
-- of the Software, and to permit persons to whom the Software is furnished to do | |
-- so, subject to the following conditions: | |
-- | |
-- The above copyright notice and this permission notice shall be included in all | |
-- copies or substantial portions of the Software. | |
-- | |
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
-- SOFTWARE. | |
-- | |
local json = require 'json' | |
usage = [[ | |
Usage: lua upload-steam-achievements.lua ach_data.lua images_dir app_id "cookie" | |
- ach_data.lua: see below for format | |
- images_dir : path to where the achievement icons specified in ach_data.lua are | |
- app_id : Steam application id | |
- cookie : taken from the browser (Inspector/Network copy as cURL) after loggin in. | |
Must include params like sessionid, steamLoginSecure, steamMachineAuth. | |
eg: "requestedPrimaryPublisher=999; steamLoginSecure=76...; sessionid=eb...; steamMachineAuth76..." | |
ach_data format: | |
{ | |
locales = {'de', 'en', ... }, -- order in which the names and description localizations appear | |
data = { | |
{ id='ACH_ID', icon='filename.jpg', icon_locked='filename.jpg', name={'name in DE', ... }, desc={'desc in DE', ...} }, | |
... | |
} | |
} | |
]] | |
------------------------------------------------------------ | |
-- constants | |
DEBUG = false | |
-- locales names mapped to internal names used by Steam | |
local loc_names = { | |
['ar'] = "arabic", | |
['bg'] = "bulgarian", | |
['cs'] = "czech", | |
['da'] = "danish", | |
['de'] = "german", | |
['el'] = "greek", | |
['en'] = "english", | |
['es'] = "spanish", | |
['fi'] = "finnish", | |
['fr'] = "french", | |
['hu'] = "hungarian", | |
['it'] = "italian", | |
['ja'] = "japanese", | |
['ko'] = "koreana", | |
['nl'] = "dutch", | |
['no'] = "norwegian", | |
['pl'] = "polish", | |
['pt'] = "portuguese", | |
['pt-BR'] = "brazilian", | |
['ro'] = "romanian", | |
['ru'] = "russian", | |
['sv'] = "swedish", | |
['th'] = "thai", | |
['tr'] = "turkish", | |
['uk'] = "ukrainian", | |
['zh-Hans'] = "schinese", | |
['zh-Hant'] = "tchinese", | |
} | |
------------------------------------------------------------ | |
local p_data_fn = arg[1] | |
local p_img_path = arg[2] | |
local p_app_id = arg[3] | |
local p_cookie = arg[4] | |
if not p_data_fn or not p_img_path or not p_app_id or not p_cookie then | |
io.stderr:write(usage) | |
os.exit(-1) | |
end | |
local data_f = io.open(p_data_fn) | |
assert(data_f, 'could not open achievements data ' .. p_data_fn) | |
local data_s = data_f:read('*a') | |
local data = load(data_s)() | |
local p_session_id = string.match(p_cookie,'sessionid=([%a%d]+);') -- extracted from cookie | |
assert(p_session_id,'sessionid not found in cookie.') | |
local CURL = string.format('curl -s -b "%s"', p_cookie) | |
local U_FETCH_ACHS = string.format('https://partner.steamgames.com/apps/fetchachievements/%s', p_app_id) | |
local U_NEW_ACH = string.format('https://partner.steamgames.com/apps/newachievement/%s', p_app_id) | |
local U_SAVE_ACH = string.format('https://partner.steamgames.com/apps/saveachievement/%s', p_app_id) | |
local U_UPLOAD_IMG = 'https://partner.steamgames.com/images/uploadachievement' | |
-- reverse index locale names | |
local loc_order = {} | |
for i,v in ipairs(data.locales) do | |
loc_order[v] = i | |
end | |
------------------------------------------------------------ | |
-- util functions | |
-- return the first key for object o in the table t | |
function table.keyforobject(t, o) | |
local key = nil | |
for k,v in pairs(t) do | |
if (o == v) then | |
key = k | |
break | |
end | |
end | |
return key | |
end | |
-- different syntax for keyforobject and bool value | |
function table.contains(t,o) | |
return (table.keyforobject(t,o) ~= nil ) | |
end | |
local function urlencode(str) | |
--Ensure all newlines are in CRLF form | |
str = string.gsub (str, "\r?\n", "\r\n") | |
--Percent-encode all non-unreserved characters | |
--as per RFC 3986, Section 2.3 | |
--(except for space, which gets plus-encoded) | |
str = string.gsub (str, "([^%w%-%.%_%~ ])", | |
function (c) return string.format ("%%%02X", string.byte(c)) end) | |
--Convert spaces to plus signs | |
str = string.gsub (str, " ", "+") | |
return str | |
end | |
------------------------------------------------------------ | |
-- ajax functions | |
local function ajax(args) | |
if DEBUG then | |
print('request >>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>') | |
print(args) | |
end | |
local h = io.popen(CURL .. ' ' .. args) | |
local res = h:read('*a') | |
h:close() | |
if DEBUG then | |
print('result <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<') | |
print(res) | |
print('---------------------------------------------------------------------') | |
end | |
local dl = json.decode(res) | |
return dl | |
end | |
local function fetch_achs() | |
-- list existing achievements | |
return ajax(U_FETCH_ACHS) | |
end | |
local function new_ach(statid,bitid) | |
-- {"success":1,"achievement":{"stat_id":1,"bit_id":0,"api_name":"NEW_ACHIEVEMENT_1_0","display_name":"NEW_ACHIEVEMENT_NAME_1_0","description":"NEW_ACHIEVEMENT_DESC_1_0","permission":0,"hidden":"","icon":"https:\/\/steamcdn-a.akamaihd.net\/steamcommunity\/public\/images\/apps\/816340\/0000000000000000000000000000000000000000.jpg","icon_gray":"https:\/\/steamcdn-a.akamaihd.net\/steamcommunity\/public\/images\/apps\/816340\/0000000000000000000000000000000000000000.jpg","progress":false},"maxstatid":1,"maxbitid":0} | |
local al = { | |
'sessionid=' .. p_session_id, | |
'maxstatid=' .. statid, | |
'maxbitid=' .. bitid, | |
} | |
local args = table.concat(al, '&') | |
local dl = ajax(string.format('-d "%s" %s', args, U_NEW_ACH)) | |
return dl.achievement.stat_id,dl.achievement.bit_id | |
end | |
local function save_ach(statid, bitid, apiname, names, descs, permission, hidden, progressStat, progressMin, progressMax) | |
-- NOTES | |
-- - it seems the images are inferred from the stat/bit id, and | |
-- are not needed to be saved with the rest of the data. | |
-- defaults | |
permission = permission or '0' | |
hidden = hidden or 'false' | |
progressStat = progressStat or '-1' | |
progressMin = progressMin or '0' | |
progressMax = progressMax or '0' | |
local displayname = '{' | |
local description = '{' | |
for k,idx in pairs(loc_order) do | |
local sk = loc_names[k] | |
displayname = displayname .. '"'..sk..'":"'..names[idx]..'",' | |
description = description .. '"'..sk..'":"'..descs[idx]..'",' | |
end | |
displayname = displayname .. '"token":"NEW_ACHIEVEMENT_'..statid..'_'..bitid..'_NAME"}' | |
description = description .. '"token":"NEW_ACHIEVEMENT_'..statid..'_'..bitid..'_DESC"}' | |
local args_list = { | |
'sessionid=' .. p_session_id, | |
'statid=' .. statid, | |
'bitid=' .. bitid, | |
'apiname=' .. apiname, | |
"displayname=" .. urlencode(displayname), | |
"description=" .. urlencode(description), | |
'permission=' .. permission, -- Client:0, GS:1, Official GS:2 | |
'hidden=' .. hidden, | |
'progressStat=' .. progressStat, -- None = -1 | |
'progressMin=' .. progressMin, | |
'progressMax=' .. progressMax, | |
} | |
local args = table.concat(args_list, '&') | |
local dl = ajax(string.format('-d "%s" %s', args, U_SAVE_ACH)) | |
return dl | |
end | |
local function upload_image(statid, bitid, locked, filename) | |
local args_list = { | |
'sessionid=' .. p_session_id, | |
'MAX_FILE_SIZE=' .. '3000000', | |
'appID=' .. p_app_id, | |
'statID=' .. statid, | |
'bit=' .. bitid, | |
'requestType=' .. (locked and 'achievement_gray' or 'achievement'), | |
'image=@' .. filename, | |
} | |
local args = '' | |
for _,a in pairs(args_list) do | |
args = args .. '-F "' .. a .. '" ' | |
end | |
local dl = ajax(string.format('%s %s', args, U_UPLOAD_IMG)) | |
return dl | |
end | |
------------------------------------------------------------ | |
-- list achievements | |
local l = fetch_achs() | |
-- filter out existing achievements | |
local fdata = {} | |
local l_ids = {} | |
for _,lrow in pairs(l.achievements) do | |
table.insert(l_ids, lrow.api_name) | |
end | |
for _,row in pairs(data.data) do | |
if not table.contains(l_ids, row.id) then | |
table.insert(fdata, row) | |
end | |
end | |
if #fdata < 1 then | |
print('all achievements already exist. nothing to upload.') | |
os.exit(0) | |
end | |
-- find last statid,bitid | |
local statid,bitid = 0,-1 | |
for _,lrow in pairs (l.achievements) do | |
if lrow.stat_id >= statid then | |
statid = lrow.stat_id | |
if lrow.bit_id > bitid then | |
bitid = lrow.bit_id | |
end | |
end | |
end | |
if DEBUG then | |
print(string.format('starting with statid:%s bitid:%s', statid, bitid)) | |
end | |
-- add the achievements | |
local res | |
for _,row in pairs(fdata) do | |
print(string.format('uploading ach %s', row.id)) | |
-- get new stat/bit | |
statid,bitid = new_ach(statid,bitid) | |
print(string.format(' statid:%s, bitid:%s', statid, bitid)) | |
if statid == 0 and bitid == -1 then | |
print('error getting stat/bit id') | |
break | |
end | |
-- create the ach | |
res = save_ach(statid, bitid, row.id, row.name, row.desc) | |
if not res or res.success ~= 1 then | |
print(string.format('error saving ach %s', row.id)) | |
break | |
end | |
-- upload the images | |
res = upload_image(statid, bitid, false, p_img_path ..'/'.. row.icon) | |
if not res or res.success ~= true then | |
print(string.format('error uploading icon %s for ach %s', row.icon, row.id)) | |
break | |
end | |
res = upload_image(statid, bitid, true, p_img_path ..'/'.. row.icon_locked) | |
if not res or res.success ~= true then | |
print(string.format('error uploading icon locked %s for ach %s', row.icon_locked, row.id)) | |
break | |
end | |
if DEBUG then | |
print('DEBUG ON. Breaking after uploading one achievement') | |
break | |
end | |
end | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment