Files
sotn-decomp/tools/progress.py
T
Jonathan Hohle e6cc85dbff Refactor WEAPON and Add WEAPON1 Build (#3813)
This change adds `WEAPON1` to the `us` build by refactoring the overlay
header and `g_HandId`. Where possible, objects are reused between both
weapon overlay bundles.

Based on the PSP weapon layout it appeared that the header data and
`g_HandId` for each weapon were in separate objects from the weapon code
and data. Separating them allows the link order to be rearranged between
platforms without any code changes or macros. This also eliminates the
need to special case function interfaces that differed between weapons.
The header only needs to resolve the symbol at link time.

A single `w_init` object can be used for all weapons, regardless of
hand. `left_hand` and `right_hand` objects can be linked to `w0_*` and
`w1_*` weapons, respectively. In the majority of cases, a specific
weapon object just needs to be linked to the corresponding "hand" object
to match for both `WEAPON0` and `WEAPON1`.

This involves a lot of file edits, but the changes are pretty simple:

* remove the header `Weapon` struct from each weapon source (defined in
`weapon_private.h`) and move into `w_init.c`
* remove the `g_HandId` global from each weapon source and move into
`left_hand.c` and `right_hand.c`
* configure segments for `w_init` and hand and update the build system
how to handle multiple data segments.

Because `w_init` is resolved at link time, all weapon API functions are
no longer `static`. These functions are no longer compiled with the
header, so no compile time switches are needed to synchronize the header
struct interfaces with the actual function interfaces.

Not all `WEAPON1` weapons are matched, primarily because of missing
data. This requires either symbol resolution or data imports. This can
be done incrementally. Weapons that haven't been matched are using the
`asm` segment type out of convenience. The build system has bespoke
rules for weapons and this avoided the complexity of generating C stubs
for now.

There are some weapons that cannot share the same weapon object. For
example `w0_051` and `w1_051` differ by one byte in the data segment.
These will need to be split into separate objects in a future change.

It's not clear to me that the `progress.py` changes will work correctly.

Submitting as draft for now for discussion.
2026-11-05 12:02:59 +11:00

499 lines
19 KiB
Python

#!/usr/bin/env python3
import argparse
import mapfile_parser
import mapfile_parser.utils
import mapfile_parser.frontends.upload_frogress
import os
import requests
import subprocess
import sys
import yaml
from pathlib import Path
from mapfile_parser import MapFile
from mapfile_parser import ProgressStats
slug = "sotn"
parser = argparse.ArgumentParser(description="Report decompilation progress")
parser.add_argument("--version", required=False, type=str, help="Game version")
parser.add_argument(
"--dry-run",
dest="dryrun",
default=False,
required=False,
action="store_true",
help="Print the request instead of posting it to the server",
)
args = parser.parse_args()
if args.version == None:
args.version = os.getenv("VERSION")
if args.version == None:
args.version = "us"
def printerr(msg: str):
print(msg, file=sys.stderr)
def exiterr(msg: str):
printerr(msg)
exit(-1)
def get_git_commit_message() -> str:
return (
subprocess.check_output(["git", "show", "-s", "--format=%s"])
.decode("utf-8")
.rstrip()
)
class DecompProgressStats:
name: str
exists: bool
data_imported: int
data_total: int
code_matching: int
code_total: int
functions_matching: int
functions_total: int
code_matching_prev: int
functions_prev: int
data_prev: int
def get_nonmatchings_path(self, asm_path: str, splat_options: dict) -> Path:
"""
Returns one of the following valid paths:
`asm/us/main/nonmatchings`
`asm/us/st/wrp/nonmatchings`
`asm/pspeu/dra_psp/psp/dra_psp`
`asm/pspeu/st/wrp_psp/psp/wrp_psp`
"""
nonmatchings = (
f"{asm_path}/{splat_options.get("nonmatchings_path", "nonmatchings")}"
)
nonmatchings_psp = f"{nonmatchings}/{os.path.basename(asm_path)}"
if os.path.exists(nonmatchings_psp) and os.path.exists(f"{asm_path}/matchings"):
return Path(nonmatchings_psp)
return Path(nonmatchings)
def __init__(self, module_name: str, path: str, src_dir: str = None):
self.name = module_name
self.data_imported = 0
self.data_total = 0
self.code_matching = 0
self.code_total = 0
self.functions_matching = 0
self.functions_total = 0
if src_dir == None:
src_dir = path
splat_config_path = f"config/splat.{args.version}.{module_name}.yaml"
map_path = None
if os.path.exists(splat_config_path):
with open(
f"config/splat.{args.version}.{module_name}.yaml"
) as config_yaml_ref:
config = yaml.safe_load(config_yaml_ref)
splat_options = config["options"]
map_path = Path(f"{splat_options["build_path"]}/{module_name}.map")
else:
printerr(f"Config yaml not found {splat_config_path}")
self.exists = False
return
# first check for path override
if map_path is None or not os.path.exists(map_path):
map_path = Path(f"build/{args.version}/{path}.map")
# then fall back to path check (really only for weapons)
if not os.path.exists(map_path):
printerr(f"file '{map_path}' not found")
self.exists = False
return
self.exists = True
map_file = mapfile_parser.MapFile()
map_file.readMapFile(map_path)
asm_path = Path(splat_options["asm_path"])
nonmatchings = self.get_nonmatchings_path(asm_path, splat_options)
depth = 4 + src_dir.count("/")
self.calculate_progress(
map_file.filterBySectionType(".text"), asm_path, nonmatchings, depth
)
self.add_segment_progress(map_file.filterBySectionType(".data"))
self.add_segment_progress(map_file.filterBySectionType(".rodata"))
self.add_segment_progress(map_file.filterBySectionType(".bss"))
def add_segment_progress(self, map_file: MapFile):
for file in [file for segment in map_file for file in segment]:
if "dra_data" in str(
file.filepath
): # exclude the VAB chunk at the end of DRA.BIN
continue
self.data_total += file.size
if "src/" in str(file.filepath) or "assets/" in str(file.filepath):
self.data_imported += file.size
else:
continue
return
# modified version of mapfile_parser.MapFile.getProgress
def calculate_progress(
self, map_file: MapFile, asmPath: Path, nonmatchings: Path, pathIndex: int
):
totalStats = ProgressStats()
progressPerFolder: dict[str, ProgressStats] = dict()
for file in [file for segment in map_file for file in segment]:
if len(file) == 0:
continue
folder = file.filepath.parts[pathIndex]
if folder not in progressPerFolder:
progressPerFolder[folder] = ProgressStats()
originalFilePath = Path(*file.filepath.parts[pathIndex:])
extensionlessFilePath = originalFilePath
while extensionlessFilePath.suffix:
extensionlessFilePath = extensionlessFilePath.with_suffix("")
if asmPath != "":
fullAsmFile = asmPath / extensionlessFilePath.with_suffix(".s")
wholeFileIsUndecomped = fullAsmFile.exists()
else: # nonmatchings path does not exist, the overlay is 100% decompiled
wholeFileIsUndecomped = False
for func in file:
if func.name.endswith(".NON_MATCHING"):
continue
self.functions_total += 1
funcNonMatching = f"{func.name}.NON_MATCHING"
funcSize = 0
if func.size is not None:
funcSize = func.size
funcAsmPath = nonmatchings / extensionlessFilePath / f"{func.name}.s"
if wholeFileIsUndecomped:
totalStats.undecompedSize += funcSize
progressPerFolder[folder].undecompedSize += funcSize
elif map_file.findSymbolByName(funcNonMatching) is not None:
totalStats.undecompedSize += funcSize
progressPerFolder[folder].undecompedSize += funcSize
elif funcAsmPath.exists():
totalStats.undecompedSize += funcSize
progressPerFolder[folder].undecompedSize += funcSize
else:
self.functions_matching += 1
totalStats.decompedSize += funcSize
progressPerFolder[folder].decompedSize += funcSize
self.code_matching = totalStats.decompedSize
self.code_total = totalStats.decompedSize + totalStats.undecompedSize
class DecompProgressWeaponStats:
name: str
exists: bool
code_matching: int
code_total: int
functions_matching: int
functions_total: int
data_imported: int
data_total: int
code_matching_prev: int
functions_prev: int
def __init__(self, hand_id: int):
self.name = f"weapon{hand_id}"
self.exists = True
self.code_matching = 0
self.code_total = 0
self.functions_matching = 0
self.functions_total = 0
self.data_imported = 0
self.data_total = 0
for i in range(0, 59):
if hand_id == 1:
name = self.name
else:
name = "weapon"
stats = DecompProgressStats(name, f"weapon/w{hand_id}_{i:03d}", "weapon")
if stats.exists:
self.code_matching += stats.code_matching
self.code_total += stats.code_total
self.functions_matching += stats.functions_matching
self.functions_total += stats.functions_total
self.data_imported += stats.data_imported
self.data_total += stats.data_total
def remove_not_existing_overlays(progresses):
new_progresses = dict[str, DecompProgressStats]()
for key in progresses:
value = progresses[key]
if value.exists == True:
new_progresses[key] = value
return new_progresses
def get_progress(module_name: str, path: str) -> DecompProgressStats:
return DecompProgressStats(module_name, path)
def hydrate_previous_metrics(progresses: dict[str, DecompProgressStats], version: str):
def fetch_metrics(category, callback):
api_base_url = os.getenv("FROGRESS_API_BASE_URL")
if api_base_url:
r = requests.get(f"{api_base_url}/data/{slug}/{version}/{category}")
else:
print("Warning: api_base_url not set")
if api_base_url is None or r.status_code == 404:
for ovl in progress:
callback(ovl, 0)
return
r.raise_for_status()
res = r.json()
if (
res == None
or res[slug] == None
or res[slug][version] == None
or res[slug][version][category] == None
):
return progress
if len(res[slug][version][category]) > 0:
last_measures = res[slug][version][category][0]["measures"]
else:
last_measures = {}
for ovl in progress:
if ovl in last_measures:
last_measure = last_measures[ovl]
if last_measure != None:
callback(ovl, last_measure)
else:
callback(ovl, 0)
def set_code_prev(ovl_name, value):
progresses[ovl_name].code_matching_prev = value
def set_func_prev(ovl_name, value):
progresses[ovl_name].functions_prev = value
def set_data_prev(ovl_name, value):
progresses[ovl_name].data_prev = value
progress = remove_not_existing_overlays(progresses)
fetch_metrics("code", set_code_prev)
fetch_metrics("functions", set_func_prev)
fetch_metrics("data", set_data_prev)
def get_progress_entry(progresses: dict[str, DecompProgressStats]):
def as_code(progresses: dict[str, DecompProgressStats]):
obj = {}
for key in progresses:
overlay_progress = progresses[key]
obj[overlay_progress.name] = overlay_progress.code_matching
obj[f"{overlay_progress.name}/total"] = overlay_progress.code_total
return obj
def as_functions(progresses: DecompProgressStats):
obj = {}
for key in progresses:
overlay_progress = progresses[key]
obj[overlay_progress.name] = overlay_progress.functions_matching
obj[f"{overlay_progress.name}/total"] = overlay_progress.functions_total
return obj
def as_data(progresses: DecompProgressStats):
obj = {}
for key in progresses:
overlay_progress = progresses[key]
obj[overlay_progress.name] = overlay_progress.data_imported
obj[f"{overlay_progress.name}/total"] = overlay_progress.data_total
return obj
return {
"timestamp": mapfile_parser.utils.getGitCommitTimestamp(),
"git_hash": mapfile_parser.utils.getGitCommitHash(),
"categories": {
"code": as_code(progresses),
"functions": as_functions(progresses),
"data": as_data(progresses),
},
}
def report_stdout(entry):
print(entry)
def report_human_readable_dryrun(progresses: dict[str, DecompProgressStats]):
for overlay in progresses:
stat = progresses[overlay]
if (
stat.code_matching != stat.code_matching_prev
or stat.data_imported != stat.data_prev
):
coverage = stat.code_matching / stat.code_total
coverage_diff = (
stat.code_matching - stat.code_matching_prev
) / stat.code_total
funcs = stat.functions_matching / stat.functions_total
funcs_diff = (
stat.functions_matching - stat.functions_prev
) / stat.functions_total
data = stat.data_imported / stat.data_total
data_diff = (stat.data_imported - stat.data_prev) / stat.data_total
report = f"{overlay.upper()} ({args.version}): "
if stat.code_matching != stat.code_matching_prev:
report += f"coverage {coverage*100:.2f}% ({coverage_diff*100:+.2f}%) "
report += f"funcs {funcs*100:.2f}% ({funcs_diff*100:+.2f}%) "
if stat.data_imported != stat.data_prev:
report += f"data {data*100:.2f}% ({data_diff*100:+.2f}%) "
print(report)
else:
print(f"{overlay.upper()} no new progress")
def report_frogress(entry, version):
api_base_url = os.getenv("FROGRESS_API_BASE_URL")
url = f"{api_base_url}/data/{slug}/{version}/"
requests.post(
url, json={"api_key": os.getenv("FROGRESS_API_SECRET"), "entries": [entry]}
).raise_for_status()
def report_discord(progresses: dict[str, DecompProgressStats]):
report = ""
for overlay in progresses:
stat = progresses[overlay]
if (
stat.code_matching != stat.code_matching_prev
or stat.data_imported != stat.data_prev
):
coverage = stat.code_matching / stat.code_total
coverage_diff = coverage - (stat.code_matching_prev / stat.code_total)
funcs = stat.functions_matching / stat.functions_total
funcs_diff = funcs - (stat.functions_prev / stat.functions_total)
data = stat.data_imported / stat.data_total
data_diff = (stat.data_imported - stat.data_prev) / stat.data_total
if coverage == 1.0 and coverage_diff < 0 and data == 1.0 and data_diff < 0:
continue # ignore flukes of negative reports with 100% progress on refactor commits
report += f"**{overlay.upper()} ({args.version})**:"
if stat.code_matching != stat.code_matching_prev:
report += f"coverage {coverage*100:.2f}% ({coverage_diff*100:+.2f}%) "
report += f"funcs {funcs*100:.2f}% ({funcs_diff*100:+.2f}%) "
if stat.data_imported != stat.data_prev:
report += f"data {data*100:.2f}% ({data_diff*100:+.2f}%) "
report += "\n"
if len(report) == 0:
# nothing to report, do not send any message to Discord
return
url = os.getenv("DISCORD_PROGRESS_WEBHOOK")
data = {
"username": "Progress",
"embeds": [
{
"title": get_git_commit_message(),
"description": report,
}
],
}
requests.post(url, json=data).raise_for_status()
if __name__ == "__main__":
progress = dict[str, DecompProgressStats]()
progress["main"] = DecompProgressStats("main", "main")
progress["dra"] = DecompProgressStats("dra", "dra")
progress["weapon"] = DecompProgressWeaponStats(0)
progress["weapon1"] = DecompProgressWeaponStats(1)
progress["ric"] = DecompProgressStats("ric", "ric")
progress["maria"] = DecompProgressStats("maria", "maria")
progress["stare"] = DecompProgressStats("stare", "st/are")
progress["stcat"] = DecompProgressStats("stcat", "st/cat")
progress["stcen"] = DecompProgressStats("stcen", "st/cen")
progress["stchi"] = DecompProgressStats("stchi", "st/chi")
progress["stdai"] = DecompProgressStats("stdai", "st/dai")
progress["stdre"] = DecompProgressStats("stdre", "st/dre")
progress["stlib"] = DecompProgressStats("stlib", "st/lib")
progress["stmad"] = DecompProgressStats("stmad", "st/mad")
progress["stno0"] = DecompProgressStats("stno0", "st/no0")
progress["stno1"] = DecompProgressStats("stno1", "st/no1")
progress["stno2"] = DecompProgressStats("stno2", "st/no2")
progress["stno3"] = DecompProgressStats("stno3", "st/no3")
progress["stno4"] = DecompProgressStats("stno4", "st/no4")
progress["stnp3"] = DecompProgressStats("stnp3", "st/np3")
progress["stnz0"] = DecompProgressStats("stnz0", "st/nz0")
progress["stnz1"] = DecompProgressStats("stnz1", "st/nz1")
progress["stsel"] = DecompProgressStats("stsel", "st/sel")
progress["stst0"] = DecompProgressStats("stst0", "st/st0")
progress["sttop"] = DecompProgressStats("sttop", "st/top")
progress["stwrp"] = DecompProgressStats("stwrp", "st/wrp")
progress["strare"] = DecompProgressStats("strare", "st/rare")
progress["strcen"] = DecompProgressStats("strcen", "st/rcen")
progress["strcat"] = DecompProgressStats("strcat", "st/rcat")
progress["strchi"] = DecompProgressStats("strchi", "st/rchi")
progress["strdai"] = DecompProgressStats("strdai", "st/rdai")
progress["strlib"] = DecompProgressStats("strlib", "st/rlib")
progress["strno0"] = DecompProgressStats("strno0", "st/rno0")
progress["strno1"] = DecompProgressStats("strno1", "st/rno1")
progress["strno2"] = DecompProgressStats("strno2", "st/rno2")
progress["strno3"] = DecompProgressStats("strno3", "st/rno3")
progress["strno4"] = DecompProgressStats("strno4", "st/rno4")
progress["strnz0"] = DecompProgressStats("strnz0", "st/rnz0")
progress["strnz1"] = DecompProgressStats("strnz1", "st/rnz1")
progress["strtop"] = DecompProgressStats("strtop", "st/rtop")
progress["strwrp"] = DecompProgressStats("strwrp", "st/rwrp")
progress["bomar"] = DecompProgressStats("bomar", "boss/mar")
progress["bobo0"] = DecompProgressStats("bobo0", "boss/bo0")
progress["bobo1"] = DecompProgressStats("bobo1", "boss/bo1")
progress["bobo2"] = DecompProgressStats("bobo2", "boss/bo2")
progress["bobo3"] = DecompProgressStats("bobo3", "boss/bo3")
progress["bobo4"] = DecompProgressStats("bobo4", "boss/bo4")
progress["bobo5"] = DecompProgressStats("bobo5", "boss/bo5")
progress["bobo6"] = DecompProgressStats("bobo6", "boss/bo6")
progress["bobo7"] = DecompProgressStats("bobo7", "boss/bo7")
progress["borbo0"] = DecompProgressStats("borbo0", "boss/rbo0")
progress["borbo1"] = DecompProgressStats("borbo1", "boss/rbo1")
progress["borbo2"] = DecompProgressStats("borbo2", "boss/rbo2")
progress["borbo3"] = DecompProgressStats("borbo3", "boss/rbo3")
progress["borbo4"] = DecompProgressStats("borbo4", "boss/rbo4")
progress["borbo5"] = DecompProgressStats("borbo5", "boss/rbo5")
progress["borbo6"] = DecompProgressStats("borbo6", "boss/rbo6")
progress["borbo7"] = DecompProgressStats("borbo7", "boss/rbo7")
progress["borbo8"] = DecompProgressStats("borbo8", "boss/rbo8")
progress["tt_000"] = DecompProgressStats("tt_000", "servant/tt_000")
progress["tt_001"] = DecompProgressStats("tt_001", "servant/tt_001")
progress["tt_002"] = DecompProgressStats("tt_002", "servant/tt_002")
progress["tt_003"] = DecompProgressStats("tt_003", "servant/tt_003")
progress["tt_004"] = DecompProgressStats("tt_004", "servant/tt_004")
progress["tt_005"] = DecompProgressStats("tt_005", "servant/tt_005")
progress["tt_006"] = DecompProgressStats("tt_006", "servant/tt_006")
progress["fname"] = DecompProgressStats("fname", "servant/fname")
progress["sd"] = DecompProgressStats("sd", "sd")
progress["sttek"] = DecompProgressStats("sttek", "st/tek")
hydrate_previous_metrics(progress, args.version)
progress = remove_not_existing_overlays(progress)
entry = get_progress_entry(progress)
if args.dryrun == False:
report_discord(progress)
report_frogress(entry, args.version)
else:
report_stdout(entry)
report_human_readable_dryrun(progress)