From 27371e86715f625059605402f9d62dd257dff548 Mon Sep 17 00:00:00 2001 From: Flawed <33593723+ff14wed@users.noreply.github.com> Date: Mon, 23 Feb 2026 01:04:45 -0800 Subject: [PATCH] First pass at github workflow for generating diff --- .github/workflows/minor_patch_diff.yml | 85 ++++++++++ automation/ffxiv_info.py | 226 +++++++++++++++++++++++++ automation/ffxiv_versions_global.json | 4 - vtable_diff.py | 4 +- 4 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/minor_patch_diff.yml create mode 100644 automation/ffxiv_info.py diff --git a/.github/workflows/minor_patch_diff.yml b/.github/workflows/minor_patch_diff.yml new file mode 100644 index 0000000..a88e04f --- /dev/null +++ b/.github/workflows/minor_patch_diff.yml @@ -0,0 +1,85 @@ +name: Minor Patch Diff Workflow + +on: + schedule: + - cron: '0 7 * * *' # 11PM PST is 7AM UTC + workflow_dispatch: + +jobs: + check-and-diff: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download Latest ffxiv_dx11.exe + id: downloader-latest + uses: WorkingRobot/ffxiv-downloader@v8 + with: + output-path: latest + regex: '^ffxiv_dx11\.exe$' + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Fetch Patch Info and Update Versions + id: versions + if: steps.downloader-latest.outputs.updated == 'true' || github.event_name == 'workflow_dispatch' + run: python automation/ffxiv_info.py >> $GITHUB_OUTPUT + + - name: Download Previous ffxiv_dx11.exe + if: steps.versions.outputs.is_new == 'true' + uses: WorkingRobot/ffxiv-downloader@v8 + with: + version: ${{ steps.versions.outputs.date_prev }} + output-path: previous + regex: '^ffxiv_dx11\.exe$' + + - name: Install Analysis Tools + if: steps.versions.outputs.is_new == 'true' + run: | + sudo apt-get update + sudo apt-get install -y radare2 + + - name: Install Python Dependencies + if: steps.versions.outputs.is_new == 'true' + run: | + pip install -r requirements.txt + + - name: Run Diff + if: steps.versions.outputs.is_new == 'true' + run: | + LATEST_EXE=$(find latest -name "ffxiv_dx11.exe" | head -n 1) + PREVIOUS_EXE=$(find previous -name "ffxiv_dx11.exe" | head -n 1) + + mv "$LATEST_EXE" "ffxiv_dx11.${{ steps.versions.outputs.retail_new }}.exe" + mv "$PREVIOUS_EXE" "ffxiv_dx11.${{ steps.versions.outputs.retail_prev }}.exe" + + # Run the diffing process and capture JSON output + python vtable_diff.py "ffxiv_dx11.${{ steps.versions.outputs.retail_prev }}.exe" "ffxiv_dx11.${{ steps.versions.outputs.retail_new }}.exe" > "diffs/${{ steps.versions.outputs.retail_new }}.diff.json" + + - name: Create Pull Request + if: steps.versions.outputs.is_new == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + commit-message: "Update for retail version ${{ steps.versions.outputs.retail_new }} (${{ steps.versions.outputs.date_new }})" + title: "Update for retail version ${{ steps.versions.outputs.retail_new }} (${{ steps.versions.outputs.date_new }})" + body: | + This automatic update contains the following changes: + - Updated `ffxiv_versions_global.json` + - Generated vtable diff for retail version ${{ steps.versions.outputs.retail_new }} + branch: "auto-update-${{ steps.versions.outputs.date_new }}" + base: "main" + delete-branch: true + + - name: Upload Results + if: steps.versions.outputs.is_new == 'true' + uses: actions/upload-artifact@v4 + with: + name: vtable-diffs-${{ steps.versions.outputs.date_new }} + path: | + diffs/*.diff.json diff --git a/automation/ffxiv_info.py b/automation/ffxiv_info.py new file mode 100644 index 0000000..622ab4b --- /dev/null +++ b/automation/ffxiv_info.py @@ -0,0 +1,226 @@ +import os +import sys +import re +import json +import urllib.request +from typing import Optional, List + +# --- Constants --- +THALIAK_REPO = "4e9a232b" +THALIAK_API = f"https://thaliak.xiv.dev/api/v2beta/repositories/{THALIAK_REPO}/patches" +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +VERSIONS_FILE = os.path.join(BASE_DIR, "automation", "ffxiv_versions_global.json") + +# --- Utilities --- + + +def fetch_url(url, is_json=False): + print(f"Fetching {url}...", file=sys.stderr) + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "accept": "application/json", + } + req = urllib.request.Request(url, headers=headers) + try: + with urllib.request.urlopen(req) as response: + content = response.read().decode("utf-8") + if is_json: + return json.loads(content) + return content + except Exception as e: + print(f" Error fetching {url}: {e}", file=sys.stderr) + return None + + +# --- Version Info Helpers --- + + +def get_version_sort_key(v): + """ + Generates a sortable key for FFXIV versions. + Handles both strings (e.g., '7.05h2') and dicts containing 'retail_version'. + """ + if isinstance(v, dict): + v = v.get("retail_version", "0.0") + # Using a list of (type_priority, value) tuples to avoid comparing list vs int + # Priorities: 0 for numeric, 1 for alphabetic + parts = re.findall(r"(\d+|[a-zA-Z]+)", v) + return [(0, int(p)) if p.isdigit() else (1, [ord(c) for c in p]) for p in parts] + + +def load_versions() -> List[dict]: + """Loads all registered versions from the global registry.""" + if os.path.exists(VERSIONS_FILE): + try: + with open(VERSIONS_FILE, "r") as f: + data = json.load(f) + if isinstance(data, list): + data.sort(key=get_version_sort_key) + return data + except (json.JSONDecodeError, IOError) as e: + print(f"Warning: Failed to load versions file: {e}", file=sys.stderr) + return [] + + +def get_latest_version_entry() -> Optional[dict]: + """Returns the most recent version entry from the version file.""" + data = load_versions() + return data[-1] if data else None + + +def fetch_latest_thaliak_patch() -> Optional[str]: + """ + Fetches the latest date-based version string from the Thaliak API. + """ + data = fetch_url(THALIAK_API, is_json=True) + if not data or "patches" not in data or not data["patches"]: + return None + return data["patches"][-1]["version_string"] + + +# --- Lodestone Scraper --- + + +def parse_lodestone_news_list(html): + """ + Parses the Lodestone news category page to extract individual news items. + """ + if not html: + return [] + pattern = re.compile( + r'
  • .*?]*>.*?

    (?:]*>.*?)?(?P.*?)</p>.*?ldst_strftime\((?P<timestamp>\d+)', + re.DOTALL, + ) + items = [] + for match in pattern.finditer(html): + items.append( + { + "url": "https://na.finalfantasyxiv.com" + match.group("url"), + "title": match.group("title").strip(), + "timestamp": int(match.group("timestamp")), + } + ) + return items + + +def extract_patch_version(html): + """Extracts the 'X.XX' patch version from maintenance detail HTML.""" + if not html or not (m := re.search(r"patch\s+(\d+\.\d+)", html, re.I)): + return None + v = m.group(1) + return v + "0" if len(v.split(".")[1]) == 1 else v + + +def is_maintenance_post(title): + """Returns True if the title represents a primary world maintenance post.""" + t = title.lower() + return "all worlds" in t and "maintenance" in t and "follow-up" not in t + + +def format_retail_version(v, count): + """Formats the version with hotfix suffixes: 7.41, 7.41h, 7.41h2, etc.""" + if count <= 1: + return v + return f"{v}h{count - 1 if count > 2 else ''}" + + +def scrape_latest_maintenance(): + """ + Scrapes the Lodestone to find the most recent 'All Worlds Maintenance' post + and counts occurrences for that specific version to determine hotfix level. + """ + url = "https://na.finalfantasyxiv.com/lodestone/news/category/2?page=1" + html = fetch_url(url) + if not html: + return None + + news_items = parse_lodestone_news_list(html) + maintenance_log = [] + + # Identify all relevant maintenance posts on the first page + for item in news_items: + if is_maintenance_post(item["title"]): + v = extract_patch_version(fetch_url(item["url"])) + if v: + item["version"] = v + maintenance_log.append(item) + + if not maintenance_log: + return None + + # Assumes hotfixes appear as separate maintenance posts with the same + # retail version. + counts = {} + for item in reversed(maintenance_log): + v = item["version"] + counts[v] = counts.get(v, 0) + 1 + item["retail_version"] = format_retail_version(v, counts[v]) + + latest = maintenance_log[0] + print( + f" Final Scrape Result: {latest['title']} -> {latest['retail_version']}", + file=sys.stderr, + ) + return latest + + +def get_patch_context(): + """ + Returns a dictionary containing: + - retail_prev, date_prev: The latest registered version. + - retail_new, date_new: The latest versions found externally. + - is_new: True if date_new is not in the registry. + """ + versions = load_versions() + prev = versions[-1] if versions else None + + date_new = fetch_latest_thaliak_patch() + + maintenance = scrape_latest_maintenance() + retail_new = maintenance["retail_version"] if maintenance else None + + is_new = False + if date_new: + is_new = not any(v["version_string"] == date_new for v in versions) + + return { + "retail_prev": prev["retail_version"] if prev else None, + "date_prev": prev["version_string"] if prev else None, + "retail_new": retail_new, + "date_new": date_new, + "is_new": is_new, + } + + +def update_and_get_info(): + """ + Discovers current patch context, prints results for GitHub Actions, + and updates the local registration if a new version is found. + """ + ctx = get_patch_context() + + if ctx["date_prev"]: + print(f"date_prev={ctx['date_prev']}") + if ctx["retail_prev"]: + print(f"retail_prev={ctx['retail_prev']}") + if ctx["date_new"]: + print(f"date_new={ctx['date_new']}") + if ctx["retail_new"]: + print(f"retail_new={ctx['retail_new']}") + print(f"is_new={'true' if ctx['is_new'] else 'false'}") + + if ctx["is_new"] and ctx["date_new"] and ctx["retail_new"]: + data = load_versions() + data.append( + {"retail_version": ctx["retail_new"], "version_string": ctx["date_new"]} + ) + print( + f"Added new version mapping: {ctx['retail_new']} -> {ctx['date_new']}", + file=sys.stderr, + ) + with open(VERSIONS_FILE, "w") as f: + json.dump(data, f, indent=2) + + +if __name__ == "__main__": + update_and_get_info() diff --git a/automation/ffxiv_versions_global.json b/automation/ffxiv_versions_global.json index 3b1cae4..2be481c 100644 --- a/automation/ffxiv_versions_global.json +++ b/automation/ffxiv_versions_global.json @@ -122,9 +122,5 @@ { "retail_version": "7.41", "version_string": "2026.01.21.0000.0000" - }, - { - "retail_version": "7.41h", - "version_string": "2026.01.30.0000.0000" } ] \ No newline at end of file diff --git a/vtable_diff.py b/vtable_diff.py index 4514a25..6bae3df 100644 --- a/vtable_diff.py +++ b/vtable_diff.py @@ -1,5 +1,6 @@ import click import json +import sys from analysis_utils import get_correct_switch from utils import eprint, create_r2_byte_pattern, sync_r2_output @@ -103,8 +104,9 @@ def diff_exes(old_exe, new_exe): if len(old_opcodes_db) != len(new_opcodes_db): eprint( - f"WARNING: vtables have different sizes: {len(old_opcodes_db)} != {len(new_opcodes_db)}. Matches may not be correct." + f"ERROR: vtables have different sizes: {len(old_opcodes_db)} != {len(new_opcodes_db)}. Refusing to continue." ) + sys.exit(1) opcodes_found = find_opcode_matches(old_opcodes_db, new_opcodes_db) opcodes_object = []