First pass at github workflow for generating diff
This commit is contained in:
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -122,9 +122,5 @@
|
|||||||
{
|
{
|
||||||
"retail_version": "7.41",
|
"retail_version": "7.41",
|
||||||
"version_string": "2026.01.21.0000.0000"
|
"version_string": "2026.01.21.0000.0000"
|
||||||
},
|
|
||||||
{
|
|
||||||
"retail_version": "7.41h",
|
|
||||||
"version_string": "2026.01.30.0000.0000"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
+3
-1
@@ -1,5 +1,6 @@
|
|||||||
import click
|
import click
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
from analysis_utils import get_correct_switch
|
from analysis_utils import get_correct_switch
|
||||||
from utils import eprint, create_r2_byte_pattern, sync_r2_output
|
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):
|
if len(old_opcodes_db) != len(new_opcodes_db):
|
||||||
eprint(
|
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_found = find_opcode_matches(old_opcodes_db, new_opcodes_db)
|
||||||
opcodes_object = []
|
opcodes_object = []
|
||||||
|
|||||||
Reference in New Issue
Block a user