Skip to content

Commit e6b4079

Browse files
committed
test(cli): verify Windows build and byte-preserving patches
1 parent ac64a07 commit e6b4079

2 files changed

Lines changed: 103 additions & 1 deletion

File tree

‎.github/workflows/build.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ jobs:
2222
python-version: '3.13'
2323

2424
- name: Build .NET Framework projects
25-
run: msbuild FireEmblemThreeHouse.sln /t:Rebuild /p:Configuration=Release /p:Platform="Any CPU" /v:minimal
25+
run: msbuild FireEmblemThreeHouse.sln /restore /t:Rebuild /p:Configuration=Release /p:Platform="Any CPU" /v:minimal
26+
27+
- name: Verify CLI save editing
28+
run: python tools/test_cli.py --cli FethCli/bin/Release/net472/FETH_Cli.exe
2629

2730
- name: Package and verify downloads
2831
run: python tools/package.py

‎tools/test_cli.py‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
#!/usr/bin/env python3
2+
"""Exercise the CLI against a synthetic v23 save without publishing user data."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import json
8+
import struct
9+
import subprocess
10+
import tempfile
11+
from pathlib import Path
12+
13+
14+
SAVE_SIZE = 0x25B20 + 12
15+
16+
17+
def checksum(data: bytes) -> int:
18+
return sum(data[12:]) & 0xFFFFFFFF
19+
20+
21+
def run(cli: Path, *args: str, success: bool = True) -> dict:
22+
result = subprocess.run([str(cli), *args], capture_output=True, text=True)
23+
if (result.returncode == 0) != success:
24+
raise AssertionError((result.returncode, result.stdout, result.stderr))
25+
return json.loads(result.stdout if success else result.stderr)
26+
27+
28+
def main() -> None:
29+
parser = argparse.ArgumentParser()
30+
parser.add_argument("--cli", type=Path, required=True)
31+
cli = parser.parse_args().cli.resolve()
32+
33+
with tempfile.TemporaryDirectory() as temporary:
34+
directory = Path(temporary)
35+
source = directory / "slot00"
36+
target = directory / "edited-slot00"
37+
patch_file = directory / "patch.json"
38+
raw = bytearray(SAVE_SIZE)
39+
struct.pack_into("<II", raw, 4, 23, SAVE_SIZE)
40+
for slot in range(400):
41+
struct.pack_into("<h", raw, 12 + slot * 4, -1)
42+
sentinel = 12 + 0x644 + 0x2A
43+
raw[sentinel] = 0xA5
44+
struct.pack_into("<I", raw, 0, checksum(raw))
45+
source.write_bytes(raw)
46+
47+
summary = run(cli, "inspect", "--input", str(source), "--section", "summary")
48+
assert summary["summary"]["Money"] == 0
49+
assert run(cli, "get", "--input", str(source), "--path", "Items[0].Id")["value"] == -1
50+
catalog = run(cli, "catalog", "--type", "classes")
51+
assert len(catalog) == 100
52+
53+
patch_file.write_text(json.dumps({
54+
"expectedSha256": summary["sha256"],
55+
"operations": [
56+
{"op": "set", "path": "Player.Money", "value": 12345},
57+
{"op": "set", "path": "Activities.Reputation", "value": 321},
58+
{"op": "set", "path": "Characters[0].data.SkillExp[0]", "value": 40},
59+
{"op": "setBit", "path": "Characters[0].data.Abilities", "index": 3, "value": True},
60+
{"op": "set", "path": "Items[0].Id", "value": 65},
61+
],
62+
}), encoding="utf-8")
63+
dry = run(cli, "apply", "--input", str(source), "--patch", str(patch_file), "--dry-run")
64+
assert dry["changedBytes"] > 0
65+
assert not target.exists()
66+
67+
applied = run(cli, "apply", "--input", str(source), "--patch", str(patch_file), "--output", str(target))
68+
assert applied["changedBytes"] > 0
69+
edited = target.read_bytes()
70+
assert source.read_bytes() == raw
71+
assert edited[sentinel] == 0xA5
72+
assert struct.unpack_from("<I", edited, 0)[0] == checksum(edited)
73+
assert struct.unpack_from("<I", edited, 12 + 0x640)[0] == 1
74+
assert struct.unpack_from("<h", edited, 12)[0] == 65
75+
assert struct.unpack_from("<H", edited, 12 + 0x644 + 0x32)[0] == 40
76+
assert struct.unpack_from("<H", edited, 12 + 0x644 + 0xFC)[0] == 40
77+
assert edited[12 + 0x644 + 0x61] & 8
78+
assert struct.unpack_from("<I", edited, 12 + 0x231D9 + 0x1074)[0] == 12345
79+
assert struct.unpack_from("<I", edited, 12 + 0x250A1 + 0xC)[0] == 321
80+
81+
bad_patch = directory / "bad.json"
82+
bad_patch.write_text(json.dumps({"operations": [
83+
{"op": "set", "path": "Player.field_17D8[0]", "value": 99},
84+
]}), encoding="utf-8")
85+
error = run(cli, "apply", "--input", str(source), "--patch", str(bad_patch),
86+
"--output", str(directory / "should-not-exist"), success=False)
87+
assert "not writable" in error["error"]
88+
assert not (directory / "should-not-exist").exists()
89+
90+
suspend = directory / "suspend"
91+
suspend.write_bytes(raw + b"battle-state")
92+
error = run(cli, "inspect", "--input", str(suspend), success=False)
93+
assert "Suspend" in error["error"]
94+
95+
print("CLI save inspection, patching, byte preservation, and rejection checks passed.")
96+
97+
98+
if __name__ == "__main__":
99+
main()

0 commit comments

Comments
 (0)