First pass at github workflow for generating diff

This commit is contained in:
Flawed
2026-02-23 01:04:45 -08:00
parent 06c3a4343e
commit 27371e8671
4 changed files with 314 additions and 5 deletions
+85
View File
@@ -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
+226
View File
@@ -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'<li class="news__list">.*?<a href="(?P<url>/lodestone/news/detail/[^"]+)"[^>]*>.*?<p class="news__list--title">(?:<span[^>]*>.*?</span>)?(?P<title>.*?)</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()
-4
View File
@@ -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"
}
]
+3 -1
View File
@@ -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 = []