Skip to content

Instantly share code, notes, and snippets.

@cigumo
Last active April 2, 2023 08:04
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save cigumo/2976b5acd5223855d1da48e20452cb57 to your computer and use it in GitHub Desktop.
Save cigumo/2976b5acd5223855d1da48e20452cb57 to your computer and use it in GitHub Desktop.
Script to upload achievements to Steam
--
-- 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