duckstation tools

This commit is contained in:
Alex Vanderveen
2026-08-20 13:47:12 -04:00
parent 0e5d463356
commit d0536d7dcd
2 changed files with 138 additions and 3 deletions
+71 -3
View File
@@ -159,13 +159,23 @@ def log(msg: str) -> None:
# ---------------------------------------------------------------------------
def data_root() -> Path:
"""The RetComM shared data root — same one the launcher and Studio use."""
"""The RetComM shared data root — same one the launcher and Studio use.
The precedence must match studio_runner.cpp's retcomm_data_dir() exactly,
XDG_DATA_HOME included. If the two disagree, the GUI reports an oracle
that is not where this tool installed it, and neither side is obviously
wrong — the worst kind of bug to chase.
"""
env = os.environ.get("RETCOMM_DATA_DIR")
if env:
return Path(env).expanduser()
if sys.platform == "win32":
base = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local")
base = (os.environ.get("LOCALAPPDATA")
or os.environ.get("USERPROFILE", str(Path.home())) + "/AppData/Local")
return Path(base) / "retcomm"
xdg = os.environ.get("XDG_DATA_HOME")
if xdg:
return Path(xdg).expanduser() / "retcomm"
return Path.home() / ".local" / "share" / "retcomm"
@@ -958,8 +968,64 @@ def state(lay: Layout) -> Dict[str, str]:
return out
def status_doc(lay: Layout) -> Dict[str, Any]:
"""Everything a caller (or a GUI) needs to decide what button to offer."""
pin = load_pin()
doc: Dict[str, Any] = {}
if lay.manifest.is_file():
try:
with open(lay.manifest, "r", encoding="utf-8") as f:
doc.update(json.load(f))
except (OSError, json.JSONDecodeError):
pass
st = state(lay)
running = st["running"] != "no"
answering = st["running"].startswith("yes")
doc.update({
# Set AFTER the manifest merge: oracle.json carries its own `kind`
# ("psxrecomp-duckstation-oracle") and would otherwise overwrite this
# one, so every installed oracle would report a document Studio then
# rejects as foreign — while an uninstalled one looked fine.
"kind": "psxrecomp-oracle-status",
"version": 1,
"root": str(lay.root),
"app": str(lay.app),
"binary": str(lay.binary),
"launcher": str(lay.launcher),
"port": int(pin.get("oracle_port", 4371)),
"source": st["source"] == "present",
"deps": st["deps"] == "present",
"patch_applied": st["patch"] == "applied",
"built": st["built"] == "yes",
"installed": st["installed"] == "yes",
"running": running,
"answering": answering,
"running_detail": st["running"],
"container_needed": bool(refused_environment()),
"container_reason": refused_environment(),
"container_engine": container_engine(),
})
# One word for a status line, so a GUI does not re-derive the ladder.
if answering:
doc["state"] = "answering"
elif running:
doc["state"] = "port-busy"
elif doc["installed"]:
doc["state"] = "installed"
elif doc["built"]:
doc["state"] = "built"
elif doc["source"]:
doc["state"] = "fetched"
else:
doc["state"] = "absent"
return doc
def cmd_status(args: argparse.Namespace) -> int:
lay = Layout(Path(args.root) if args.root else None)
if args.json:
print(json.dumps(status_doc(lay), indent=1))
return 0
print(f"root: {lay.root}")
if lay.manifest.is_file():
with open(lay.manifest, "r", encoding="utf-8") as f:
@@ -1141,6 +1207,8 @@ def main(argv=None) -> int:
p.set_defaults(func=cmd_stop)
p = sub.add_parser("status", help="where it is and what state it is in")
p.add_argument("--json", action="store_true",
help="machine-readable, for RetComM Studio")
p.set_defaults(func=cmd_status)
p = sub.add_parser("path", help="print the install root")
@@ -1151,7 +1219,7 @@ def main(argv=None) -> int:
for attr, default in (("cc", None), ("cxx", None), ("jobs", 0), ("lto", False),
("reconfigure", False), ("cmake_arg", None),
("force", False), ("bios", None), ("root", None),
("wayland", False), ("container", None)):
("wayland", False), ("container", None), ("json", False)):
if not hasattr(args, attr):
setattr(args, attr, default)
try:
+67
View File
@@ -50,6 +50,24 @@ class TestLocations(EnvGuard):
self.assertEqual(DSO.oracle_root(), Path("/tmp/rc-test/oracle/duckstation"))
self.assertEqual(DSO.download_cache(), Path("/tmp/rc-test/downloads"))
def test_xdg_data_home_is_honoured_like_studio_does(self):
"""studio_runner.cpp's retcomm_data_dir() checks XDG_DATA_HOME before
~/.local/share. If this tool did not, the GUI would look for an oracle
somewhere other than where the tool installed it, and neither side would
look wrong."""
os.environ.pop("RETCOMM_ORACLE_DIR", None)
os.environ.pop("RETCOMM_DATA_DIR", None)
os.environ["XDG_DATA_HOME"] = "/tmp/xdg-test"
self.assertEqual(DSO.data_root(), Path("/tmp/xdg-test/retcomm"))
self.assertEqual(DSO.oracle_root(),
Path("/tmp/xdg-test/retcomm/oracle/duckstation"))
def test_retcomm_data_dir_beats_xdg(self):
os.environ.pop("RETCOMM_ORACLE_DIR", None)
os.environ["XDG_DATA_HOME"] = "/tmp/xdg-test"
os.environ["RETCOMM_DATA_DIR"] = "/tmp/explicit"
self.assertEqual(DSO.data_root(), Path("/tmp/explicit"))
def test_oracle_dir_override_wins(self):
os.environ["RETCOMM_DATA_DIR"] = "/tmp/rc-test"
os.environ["RETCOMM_ORACLE_DIR"] = "/tmp/elsewhere"
@@ -174,6 +192,55 @@ class TestSettingsMerge(unittest.TestCase):
self.assertIn("Renderer = Software", p.read_text())
class TestStatusDoc(EnvGuard):
"""The status document is the whole contract with RetComM Studio."""
def test_absent_install_reports_absent(self):
os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp()
doc = DSO.status_doc(DSO.Layout())
self.assertEqual(doc["kind"], "psxrecomp-oracle-status")
self.assertEqual(doc["state"], "absent")
for k in ("source", "deps", "built", "installed", "running", "answering"):
self.assertFalse(doc[k], k)
self.assertIn("port", doc)
self.assertIn("container_needed", doc)
def test_state_ladder_is_ordered(self):
"""A GUI picks its button off `state`, so the ladder must not skip."""
os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp()
lay = DSO.Layout()
lay.src.mkdir(parents=True)
(lay.src / "CMakeLists.txt").write_text("")
self.assertEqual(DSO.status_doc(lay)["state"], "fetched")
(lay.build / "bin").mkdir(parents=True)
(lay.build / "bin" / DSO.exe_name()).write_text("")
self.assertEqual(DSO.status_doc(lay)["state"], "built")
lay.app.mkdir(parents=True)
(lay.app / DSO.exe_name()).write_text("")
self.assertEqual(DSO.status_doc(lay)["state"], "installed")
def test_kind_survives_an_existing_manifest(self):
"""oracle.json has its own `kind`. Merging it must not overwrite the
status document's, or every INSTALLED oracle reports a document Studio
rejects as foreign — while an uninstalled one parses fine, so the bug
only appears once things are working."""
os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp()
lay = DSO.Layout()
lay.root.mkdir(parents=True, exist_ok=True)
lay.manifest.write_text(json.dumps({
"kind": "psxrecomp-duckstation-oracle",
"version": 1,
"upstream_base": "deadbeef",
}))
doc = DSO.status_doc(lay)
self.assertEqual(doc["kind"], "psxrecomp-oracle-status")
self.assertEqual(doc["upstream_base"], "deadbeef") # manifest data kept
def test_json_is_serialisable(self):
os.environ["RETCOMM_ORACLE_DIR"] = tempfile.mkdtemp()
json.dumps(DSO.status_doc(DSO.Layout()))
class TestPin(unittest.TestCase):
def test_pin_is_the_single_source_of_truth(self):
pin = DSO.load_pin()