|
""" |
|
This script manipulates the SCUM database for the single player mode to |
|
increase the level of all skills and attributes to max (or the value of |
|
your choice). |
|
|
|
Edit the constants below to change the target values for the skills and |
|
attributes. Default is maxed out. |
|
|
|
Tested with Python 3.13 on December 28th, 2024 with SCUM Build 0.9.605.85940 |
|
""" |
|
|
|
from dataclasses import dataclass |
|
import datetime as dt |
|
from pathlib import Path |
|
import shutil |
|
import sqlite3 |
|
import struct |
|
import traceback |
|
from typing import Literal |
|
|
|
#### Configuration #### |
|
|
|
## Main attributes ## |
|
SET_ATTRIBUTES = { |
|
"BaseStrength": 8.0, # 1.0 to 8.0 |
|
"BaseConstitution": 5.0, # 1.0 to 5.0 |
|
"BaseDexterity": 5.0, # 1.0 to 5.0 |
|
"BaseIntelligence": 8.0, # 1.0 to 8.0 |
|
} |
|
|
|
## Skills ## |
|
""" |
|
You can remove skills from the list below and they will not be changed. |
|
If a new skill is added to the game, you can add it to the list below. |
|
|
|
The first number in each line is the skill level (0 - 3) |
|
The second number is the skill experience (0 - 10000000) |
|
""" |
|
|
|
SET_SKILLS = { |
|
"BoxingSkill": (3, 10000000), |
|
"AwarenessSkill": (3, 10000000), |
|
"RiflesSkill": (3, 10000000), |
|
"SnipingSkill": (3, 10000000), |
|
"CamouflageSkill": (3, 10000000), |
|
"SurvivalSkill": (3, 10000000), |
|
"MeleeWeaponsSkill": (3, 10000000), |
|
"HandgunSkill": (3, 10000000), |
|
"RunningSkill": (3, 10000000), |
|
"EnduranceSkill": (3, 10000000), |
|
"TacticsSkill": (3, 10000000), |
|
"CookingSkill": (3, 10000000), |
|
"ThieverySkill": (3, 10000000), |
|
"ArcherySkill": (3, 10000000), |
|
"DrivingSkill": (3, 10000000), |
|
"EngineeringSkill": (3, 10000000), |
|
"DemolitionSkill": (3, 10000000), |
|
"MedicalSkill": (3, 10000000), |
|
"MotorcycleSkill": (3, 10000000), |
|
"StealthSkill": (3, 10000000), |
|
"AviationSkill": (3, 10000000), |
|
"ResistanceSkill": (3, 10000000), |
|
"FarmingSkill": (3, 10000000), |
|
} |
|
|
|
# Other constants |
|
DB_PATH = Path.home() / "AppData/Local/SCUM/Saved/SaveFiles/SCUM.db" |
|
|
|
BODY_SIM_KEY_PADDING = 5 |
|
BODY_SIM_VALUE_PADDING = 10 |
|
|
|
|
|
@dataclass |
|
class PropertyType: |
|
"""Just a small class to define property types as they occur in the body simulation blob.""" |
|
|
|
name: bytes |
|
width: int # in bytes |
|
# Used for converting with Python types |
|
struct_type: Literal["<d", "<f", "<?"] |
|
|
|
|
|
DoubleProperty = PropertyType(name=b"DoubleProperty", width=8, struct_type="<d") |
|
FloatProperty = PropertyType(name=b"FloatProperty", width=4, struct_type="<f") |
|
BoolProperty = PropertyType(name=b"BoolProperty", width=1, struct_type="<?") |
|
|
|
|
|
def load_prisoner(con: sqlite3.Connection, id: int): |
|
"""Load prisoner from database.""" |
|
cur = con.execute("SELECT * FROM prisoner WHERE id = ?", (id,)) |
|
result = {desc[0]: val for desc, val in zip(cur.description, cur.fetchone())} |
|
return result |
|
|
|
|
|
def save_prisoner(con: sqlite3.Connection, prisoner: dict): |
|
"""Updates prisoner in database. Currently only sets body_simulation.""" |
|
return con.execute( |
|
"UPDATE prisoner SET body_simulation = ? WHERE id = ?", |
|
(prisoner["body_simulation"], prisoner["id"]), |
|
) |
|
|
|
|
|
def update_body_sim( |
|
body_sim: bytearray, |
|
key: bytes, |
|
value: float, |
|
property_type: PropertyType, |
|
): |
|
# Find the key in the body simulation blob |
|
key_offset = body_sim.index(key) |
|
|
|
# Make sure we are using the correct property type |
|
property_type_offset = key_offset + len(key) + BODY_SIM_KEY_PADDING |
|
assert ( |
|
body_sim[property_type_offset : property_type_offset + len(property_type.name)] |
|
== property_type.name |
|
) |
|
|
|
# Calculate offset of actual value |
|
value_offset = ( |
|
key_offset |
|
+ len(key) |
|
+ BODY_SIM_KEY_PADDING |
|
+ len(property_type.name) |
|
+ BODY_SIM_VALUE_PADDING |
|
) |
|
|
|
# Convert value to bytes |
|
value_bytes = struct.pack(property_type.struct_type, value) |
|
|
|
# Update value in body sim blob |
|
body_sim[value_offset : value_offset + property_type.width] = value_bytes |
|
|
|
|
|
def update_skills(con: sqlite3.Connection, prisoner: dict): |
|
"""Sets all skills to max level in the database.""" |
|
|
|
for (name,) in con.execute( |
|
"SELECT name FROM prisoner_skill WHERE prisoner_id = ?", (prisoner["id"],) |
|
): |
|
if name not in SET_SKILLS: |
|
continue |
|
|
|
new_level, new_experience = SET_SKILLS[name] |
|
|
|
# Finally, update the XML and other fields in the database |
|
con.execute( |
|
"UPDATE prisoner_skill SET level = ?, experience = ? WHERE prisoner_id = ? AND name = ?", |
|
(new_level, new_experience, prisoner["id"], name), |
|
) |
|
|
|
|
|
def choose_prisoner(con: sqlite3.Connection): |
|
"""Choose prisoner to update.""" |
|
cur = con.execute( |
|
"SELECT prisoner.id, user_profile.name FROM prisoner LEFT JOIN user_profile ON prisoner.user_profile_id = user_profile.id WHERE user_profile.authority_name is ?", |
|
(None,), |
|
) |
|
prisoners = {id: name for (id, name) in cur} |
|
|
|
if not prisoners: |
|
print("No prisoners found in local single player.") |
|
return None |
|
|
|
print("\nFound prisoners in local single player:\n") |
|
|
|
for id, name in prisoners.items(): |
|
print(f'"{name}" with ID {id}') |
|
|
|
selected_id = input("\nEnter prisoner ID: ") |
|
|
|
try: |
|
selected_id = int(selected_id) |
|
except ValueError: |
|
print("Invalid input.") |
|
return None |
|
|
|
if selected_id not in prisoners: |
|
print("Please enter a valid prisoner ID.") |
|
return None |
|
|
|
return selected_id |
|
|
|
|
|
def create_backup(db_path: Path): |
|
"""Creates a backup of the database.""" |
|
filename_safe_iso = dt.datetime.now().isoformat().replace(":", "-") |
|
backup_path = db_path.with_name(f"SCUM-bak-{filename_safe_iso}.db") |
|
shutil.copy(db_path, backup_path) |
|
return backup_path |
|
|
|
|
|
def main(): |
|
if not DB_PATH.exists(): |
|
print(f"Database file {DB_PATH} not found.") |
|
input("Press enter to exit.") |
|
return |
|
|
|
print("Creating backup... ") |
|
backup_path = create_backup(DB_PATH) |
|
print(f"Backed up to: {backup_path}") |
|
|
|
print("\nConnecting to database...") |
|
con = sqlite3.connect(DB_PATH) |
|
|
|
# Choose prisoner interactively |
|
prisoner_id = choose_prisoner(con) |
|
|
|
if not prisoner_id: |
|
input("Prisoner selection failed. Press enter to exit.") |
|
return |
|
|
|
print(f"Loading prisoner with ID {prisoner_id}...") |
|
prisoner = load_prisoner(con, prisoner_id) |
|
|
|
print("\nUpdating attributes... ", end="") |
|
body_sim = bytearray(prisoner["body_simulation"]) |
|
|
|
for attribute, value in SET_ATTRIBUTES.items(): |
|
update_body_sim( |
|
body_sim, |
|
attribute.encode("ascii"), |
|
value, |
|
DoubleProperty, |
|
) |
|
|
|
prisoner["body_simulation"] = bytes(body_sim) |
|
|
|
save_prisoner(con, prisoner) |
|
print("Success!") |
|
|
|
print("Updating skills... ", end="") |
|
update_skills(con, prisoner) |
|
print("Success!") |
|
|
|
con.commit() |
|
input("\nAll done! Press enter to exit.") |
|
|
|
|
|
if __name__ == "__main__": |
|
try: |
|
main() |
|
except KeyboardInterrupt: |
|
print("\nExiting...") |
|
except Exception: |
|
print("\n\nSomething went wrong...\n\n") |
|
traceback.print_exc() |
|
input("\n\nPress enter to exit.") |
@mwpowellhtx Yes, access to the SQLite DB file is required for this script, currently it's set up to look at the default location for a single player situation.
I was considering supporting editing a server save (which I believe works essentially the same way) but I haven't been playing SCUM myself recently. I suspect only small changes would have to be made to create a version that works on a server save. I don't expect it to be possible to edit the body sim while the server is running though.
As to your second question: It has been a few years since I wrote this, but I don't mind documenting it here. Back then I went in with a hex editor to understand the structure and reverse-engineer it. If you want to fully understand it, I think you'll have to do the same, but you can also take a look at the
update_body_simfunction for guidance and practical purposes.It gets in the
body_simblob, then akey(name of the property you want to change, eg."BaseStrength"), thevalueyou want to set it to, and theproperty_type(thePropertyTypestuff is defined earlier, I'm only usingDoubleProperty, but in theory there are more types of values that can be changed). Then it calculates a bunch of offsets to get to the actual part in the blob where the value is.It's pretty straightforward to make a version of the function that reads a value from the body sim by reversing the
struct.packlogic to usestruct.unpack, which works the same way, then set the new value based on that. I created a script like that here: https://gist.github.com/jh0ker/8045a8618d41b673634a3603a00663fe (I removed the skill updating function from it). That should get you most of the way there I think.If you want to look at a body sim blob yourself and play around with it, I'll attach a quick basic guide.
You can investigate the data in more detail by exporting it to a file and using a hex editor. In this example, I am looking at the
BaseStrengthwhich is aDoublePropertywith the value3.4. There always seems to be a gap of 5 and 10 bytes respectively between the key, the property type and the value. In Python, thestructmodule is used to convert these bytes to usable values.I assume this is perhaps based on some serialization system of Unreal Engine or some library they are using, however I have no further information on that. If anyone recognizes this, feel free to leave a comment 😄
Anyways, I hope this helps you or anyone else looking at this in the future.