Author SHA1 Message Date
ff14wedandgithub-actions[bot] c629407814 Update for 7.51 (2026.05.25.0000.0000) 2026-06-02 06:40:18 +00:00
Flawed f705f99195 Restore minor patch workflow only on new
Also remove scheduled run of minor patch workflow
2026-05-09 01:25:38 -07:00
Flawed 4a3094bd79 Switch to custom downloader 2026-05-09 01:20:59 -07:00
FlawedandGitHub f44ec97a8d Merge pull request #9 from xivdev/auto-update-28ea068
Update for 7.50h (2026.05.01.0000.0000)
2026-05-07 05:16:28 -07:00
ff14wedandgithub-actions[bot] d4e943f31a Update for 7.50h (2026.05.01.0000.0000) 2026-05-07 12:12:42 +00:00
Flawed 28ea068169 fix typo 2026-04-28 00:55:02 -07:00
Flawed 555829c343 Update for 7.50 2026-04-28 00:36:25 -07:00
Flawed b01e2dc3b6 7.5 opcode offset hack 2026-04-28 00:33:31 -07:00
FlawedandGitHub 01017f29ff Merge pull request #8 from xivdev/auto-update-964e2e9
Update for 7.45h2 (2026.03.17.0000.0000)
2026-03-24 00:41:33 -07:00
ff14wedandgithub-actions[bot] ceabd258b2 Update for 7.45h2 (2026.03.17.0000.0000) 2026-03-24 07:39:04 +00:00
Flawed 964e2e909d Fix dependency order in workflow 2026-03-11 21:55:47 -07:00
Flawed 8bfa266661 Only download latest ffxiv exes if there is a new patch 2026-03-11 21:53:24 -07:00
Flawed 4ba7cfaea1 More robust hotfix version handling in automation 2026-03-11 21:50:14 -07:00
FlawedandGitHub 1c3a2e2ffe Merge pull request #7 from xivdev/auto-update-65e6d9a
Update for 7.45h (2026.03.07.0000.0000)
2026-03-11 01:32:58 -07:00
9 changed files with 25073 additions and 61 deletions
+7 -17
View File
@@ -1,8 +1,6 @@
name: Minor Patch Diff Workflow name: Minor Patch Diff Workflow
on: on:
schedule:
- cron: '0 7 * * *' # 11PM PST is 7AM UTC
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -15,13 +13,6 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- 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 - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v6
with: with:
@@ -30,16 +21,15 @@ jobs:
- name: Fetch Patch Info and Update Versions - name: Fetch Patch Info and Update Versions
id: versions id: versions
if: steps.downloader-latest.outputs.updated == 'true' || github.event_name == 'workflow_dispatch'
run: python automation/ffxiv_info.py >> $GITHUB_OUTPUT run: python automation/ffxiv_info.py >> $GITHUB_OUTPUT
- name: Download Latest ffxiv_dx11.exe
if: steps.versions.outputs.is_new == 'true'
run: python automation/download_exe.py --output latest/
- name: Download Previous ffxiv_dx11.exe - name: Download Previous ffxiv_dx11.exe
if: steps.versions.outputs.is_new == 'true' if: steps.versions.outputs.is_new == 'true'
uses: WorkingRobot/ffxiv-downloader@v8 run: python automation/download_exe.py --version "${{ steps.versions.outputs.thaliak_version_prev }}" --output previous/
with:
version: ${{ steps.versions.outputs.date_prev }}
output-path: previous
regex: '^ffxiv_dx11\.exe$'
- name: Install Analysis Tools - name: Install Analysis Tools
if: steps.versions.outputs.is_new == 'true' if: steps.versions.outputs.is_new == 'true'
@@ -78,8 +68,8 @@ jobs:
uses: peter-evans/create-pull-request@v8 uses: peter-evans/create-pull-request@v8
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "Update for ${{ steps.versions.outputs.retail_new }} (${{ steps.versions.outputs.date_new }})" commit-message: "Update for ${{ steps.versions.outputs.retail_new }} (${{ steps.versions.outputs.thaliak_version_new }})"
title: "Update for ${{ steps.versions.outputs.retail_new }} (${{ steps.versions.outputs.date_new }})" title: "Update for ${{ steps.versions.outputs.retail_new }} (${{ steps.versions.outputs.thaliak_version_new }})"
body: | body: |
This automatic update contains the following changes: This automatic update contains the following changes:
- Updated `ffxiv_versions_global.json` - Updated `ffxiv_versions_global.json`
+4 -1
View File
@@ -135,7 +135,10 @@ def get_packet_handler_opcode_offset(r2, exe_file: str):
r2.cmd(f"s {opcode_offset_target}") r2.cmd(f"s {opcode_offset_target}")
r2.cmd("aei; aeim; aeip") # Initialize ESIL VM, stack, and instruction pointer r2.cmd("aei; aeim; aeip") # Initialize ESIL VM, stack, and instruction pointer
if semver.compare(sem_ver, "7.4.0") >= 0: if semver.compare(sem_ver, "7.5.0") >= 0:
offset_reg = "r13"
opcode_offset = _get_opcode_offset_post_72(r2, offset_reg)
elif semver.compare(sem_ver, "7.4.0") >= 0:
offset_reg = "r15" offset_reg = "r15"
opcode_offset = _get_opcode_offset_post_72(r2, offset_reg) opcode_offset = _get_opcode_offset_post_72(r2, offset_reg)
elif semver.compare(sem_ver, "7.3.8") >= 0: elif semver.compare(sem_ver, "7.3.8") >= 0:
+391
View File
@@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""
download_exe.py - Extract ffxiv_dx11.exe from an FFXIV ZiPatch file by streaming.
Does not require a CLUT file. Queries the Thaliak REST API for the patch URL,
then streams and parses the ZiPatch format to assemble ffxiv_dx11.exe on the fly.
Usage:
python automation/download_exe.py --output latest/
python automation/download_exe.py --version D2025.01.14.0000.0000 --output previous/
"""
import os
import sys
import struct
import zlib
import argparse
import urllib.request
import json
from typing import Optional
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
THALIAK_REPO = "4e9a232b"
THALIAK_API = f"https://thaliak.xiv.dev/api/v2beta/repositories/{THALIAK_REPO}/patches"
# ZiPatch magic: \x91ZIPATCH\r\n\x1A\n
ZIPATCH_MAGIC = bytes(
[0x91, 0x5A, 0x49, 0x50, 0x41, 0x54, 0x43, 0x48, 0x0D, 0x0A, 0x1A, 0x0A]
)
TARGET_FILE = "ffxiv_dx11.exe"
# SqpkCompressedBlock: compressed_size == 32000 means the block is stored raw
SQPK_BLOCK_UNCOMPRESSED = 32000
# SqpkFile operation codes
OP_ADD_FILE = ord("A")
OP_REMOVE_ALL = ord("R")
OP_DELETE_FILE = ord("D")
OP_MAKE_DIR = ord("M")
# ---------------------------------------------------------------------------
# Thaliak helpers
# ---------------------------------------------------------------------------
def _fetch_json(url: str) -> dict:
req = urllib.request.Request(
url, headers={"User-Agent": "ffxiv-exe-downloader/1.0"}
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode("utf-8"))
def get_patch_url(version: Optional[str]) -> tuple[str, str]:
"""Return (version_string, remote_url) for *version* or the latest patch."""
data = _fetch_json(THALIAK_API)
patches = data.get("patches", [])
if not patches:
raise RuntimeError("No patches returned from Thaliak API")
if version is None:
return patches[-1]["version_string"], patches[-1]["remote_url"]
# The version coming from the GHA output may or may not have a D/H prefix.
for p in reversed(patches):
vs = p["version_string"]
bare = vs[1:] if vs[:1] in ("D", "H") else vs
if vs == version or bare == version:
return vs, p["remote_url"]
raise RuntimeError(f"Version {version!r} not found in Thaliak patches")
# ---------------------------------------------------------------------------
# Streaming reader
# ---------------------------------------------------------------------------
class StreamReader:
"""
Wraps an HTTP response object and exposes read_exact / skip helpers.
Reads are buffered internally so we never buffer more than needed.
"""
CHUNK = 65_536 # 64 KiB read chunks for internal buffering
def __init__(self, response) -> None:
self._resp = response
self._buf = bytearray()
self._eof = False
def _fill(self, needed: int) -> None:
while len(self._buf) < needed and not self._eof:
raw = self._resp.read(self.CHUNK)
if not raw:
self._eof = True
break
self._buf.extend(raw)
def read_exact(self, n: int) -> bytes:
if n == 0:
return b""
self._fill(n)
if len(self._buf) < n:
raise EOFError(
f"Stream ended prematurely: needed {n}, got {len(self._buf)}"
)
data = bytes(self._buf[:n])
del self._buf[:n]
return data
def skip(self, n: int) -> None:
"""Discard n bytes without buffering them all at once."""
while n > 0:
chunk = min(n, self.CHUNK)
self._fill(chunk)
take = min(chunk, len(self._buf))
if take == 0:
raise EOFError("Stream ended prematurely during skip")
del self._buf[:take]
n -= take
# ---------------------------------------------------------------------------
# ZiPatch parsing helpers
# ---------------------------------------------------------------------------
def _align_up(value: int, alignment: int) -> int:
return (value + alignment - 1) & ~(alignment - 1)
def _decompress_raw_deflate(data: bytes) -> bytes:
"""Decompress raw DEFLATE (no zlib/gzip header, wbits=-15)."""
return zlib.decompress(data, wbits=-15)
def _parse_sqpk_file_body(body: bytes) -> Optional[tuple]:
"""
Parse an SQPK command-'F' body.
Returns (operation, file_offset, file_size, path, blocks_start_offset) or None on error.
`blocks_start_offset` is the index in `body` where compressed blocks begin.
"""
# body layout (after the 4-byte inner_size + 1-byte command already consumed):
# [1: operation] [2: pad] [8: file_offset BE] [8: file_size BE]
# [4: path_len BE] [2: expansion_id BE] [2: pad] [path_len: path]
# ... compressed blocks ...
HEADER = 1 + 2 + 8 + 8 + 4 + 2 + 2 # = 27 bytes
if len(body) < HEADER:
return None
offset = 0
operation = body[offset]
offset += 1 + 2 # op + 2 pad bytes
(file_offset,) = struct.unpack_from(">q", body, offset)
offset += 8
(file_size,) = struct.unpack_from(">q", body, offset)
offset += 8
(path_len,) = struct.unpack_from(">i", body, offset)
offset += 4
offset += 4 # expansion_id (2) + pad (2)
if path_len < 0 or offset + path_len > len(body):
return None
path = (
body[offset : offset + path_len]
.decode("utf-8", errors="replace")
.rstrip("\x00")
)
offset += path_len
return operation, file_offset, file_size, path, offset
def _parse_compressed_blocks(
body: bytes, blocks_start: int, file_offset: int, out_buf: bytearray
) -> int:
"""
Parse SqpkCompressedBlock entries from `body[blocks_start:]`, decompressing each
into `out_buf` starting at `file_offset`.
Returns the final file offset after all blocks (i.e. file_offset + total decompressed bytes).
"""
pos = blocks_start
current_file_off = file_offset
while pos < len(body):
blk_start = pos
if pos + 16 > len(body):
break # not enough data for a block header
(header_size,) = struct.unpack_from("<i", body, pos)
pos += 4
pos += 4 # pad (uint32)
(compressed_size,) = struct.unpack_from("<i", body, pos)
pos += 4
(data_size,) = struct.unpack_from("<i", body, pos)
pos += 4
# Seek to blk_start + header_size (skip any extra header bytes beyond the 16 we read)
pos = blk_start + header_size
is_compressed = compressed_size != SQPK_BLOCK_UNCOMPRESSED
if is_compressed:
if pos + compressed_size > len(body):
break
raw = body[pos : pos + compressed_size]
decompressed = _decompress_raw_deflate(raw)
pos += compressed_size
else:
if pos + data_size > len(body):
break
decompressed = body[pos : pos + data_size]
pos += data_size
# Write decompressed bytes into out_buf at current_file_off,
# growing the buffer as needed (multiple SqpkFile chunks cover
# sequential ranges; each chunk's file_size is only its own share).
end = current_file_off + len(decompressed)
if end > len(out_buf):
out_buf.extend(bytes(end - len(out_buf)))
out_buf[current_file_off:end] = decompressed
current_file_off += data_size # advance by the *uncompressed* size
# Align to next 128-byte boundary from block start
consumed = pos - blk_start
aligned = _align_up(consumed, 128)
pos = blk_start + aligned
return current_file_off
# ---------------------------------------------------------------------------
# Main extraction logic
# ---------------------------------------------------------------------------
def extract_exe(url: str, output_path: str) -> bool:
"""
Stream the ZiPatch at *url*, extract ffxiv_dx11.exe, write to *output_path*.
Returns True on success.
"""
print(f"Streaming patch: {url}", file=sys.stderr)
req = urllib.request.Request(
url, headers={"User-Agent": "ffxiv-exe-downloader/1.0"}
)
with urllib.request.urlopen(req) as resp:
reader = StreamReader(resp)
# --- Verify ZiPatch magic (12 bytes) ---
magic = reader.read_exact(12)
if magic != ZIPATCH_MAGIC:
raise RuntimeError(f"Not a valid ZiPatch file (bad magic: {magic.hex()})")
out_buf: Optional[bytearray] = None
while True:
# Each chunk: [4: body_size BE] [4: fourcc] [body_size: body] [4: CRC32]
header = reader.read_exact(4)
(body_size,) = struct.unpack(">I", header)
fourcc_bytes = reader.read_exact(4)
fourcc = fourcc_bytes.decode("ascii", errors="replace")
if fourcc == "EOF_":
# EndOfFile chunk we are done
reader.skip(body_size + 4) # body + CRC
break
if fourcc != "SQPK":
# Skip non-SQPK chunks (FileHeader, ApplyOption, etc.)
reader.skip(body_size + 4)
continue
# --- SQPK chunk ---
# First 5 bytes: inner_size (4, BE) + command (1)
if body_size < 5:
reader.skip(body_size + 4)
continue
preamble = reader.read_exact(5)
command = chr(preamble[4])
if command != "F":
# Not a file command skip the rest
reader.skip(body_size - 5 + 4)
continue
# command == 'F' (SqpkFile) load the full body to parse file path and blocks
rest = reader.read_exact(body_size - 5)
reader.skip(4) # CRC
parsed = _parse_sqpk_file_body(rest)
if parsed is None:
continue
operation, file_offset, _chunk_file_size, path, blocks_start = parsed
# Check whether this chunk targets our exe (case-insensitive suffix match)
is_target = path.lower() == TARGET_FILE.lower() or path.lower().endswith(
"/" + TARGET_FILE.lower()
)
if not is_target:
continue
if operation == OP_REMOVE_ALL:
out_buf = None
print(f" RemoveAll on {path}", file=sys.stderr)
continue
if operation != OP_ADD_FILE:
continue
# AddFile: reset buffer on first write (file_offset == 0 means truncate+rewrite).
# Do NOT pre-allocate from chunk_file_size — that's only this chunk's share;
# the full exe spans many SqpkFile chunks at increasing file_offset values.
if file_offset == 0:
out_buf = bytearray()
print(f" Found {path}", file=sys.stderr)
if out_buf is None:
# Shouldn't happen for a well-formed patch
continue
_parse_compressed_blocks(rest, blocks_start, file_offset, out_buf)
if out_buf is None:
print(f"ERROR: {TARGET_FILE} not found in patch!", file=sys.stderr)
return False
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
with open(output_path, "wb") as f:
f.write(out_buf)
print(f" Written {len(out_buf):,} bytes -> {output_path}", file=sys.stderr)
return True
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description=f"Download {TARGET_FILE} from an FFXIV ZiPatch file without a CLUT."
)
parser.add_argument(
"--version",
default=None,
metavar="VERSION",
help="Thaliak version string (default: latest). Accepts D/H-prefixed or bare form.",
)
parser.add_argument(
"--output",
required=True,
metavar="PATH",
help="Output directory or file path.",
)
args = parser.parse_args()
# Resolve output path to a file
out = args.output
if out.endswith(os.sep) or os.path.isdir(out):
out = os.path.join(out, TARGET_FILE)
elif not out.lower().endswith(".exe"):
os.makedirs(out, exist_ok=True)
out = os.path.join(out, TARGET_FILE)
print("Fetching patch metadata from Thaliak...", file=sys.stderr)
version_str, patch_url = get_patch_url(args.version)
print(f" Version : {version_str}", file=sys.stderr)
print(f" URL : {patch_url}", file=sys.stderr)
success = extract_exe(patch_url, out)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+67 -43
View File
@@ -79,12 +79,6 @@ def load_versions() -> List[dict]:
return [] 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]: def fetch_latest_thaliak_patch() -> Optional[str]:
""" """
Fetches the latest date-based version string from the Thaliak API. Fetches the latest date-based version string from the Thaliak API.
@@ -131,8 +125,36 @@ def extract_patch_version(html):
if not html or not (m := re.search(r"patch\s+(\d+\.\d+)", html, re.I)): if not html or not (m := re.search(r"patch\s+(\d+\.\d+)", html, re.I)):
return None return None
v = m.group(1) v = m.group(1)
v = v + "0" if len(v.split(".")[1]) == 1 else v if len(v.split(".")[1]) == 1:
return v + "h" if "HotFixes" in html else v v += "0"
return v
def get_next_hotfix_version(base_v, existing_versions):
matching = [
v["retail_version"]
for v in existing_versions
if v["retail_version"].startswith(base_v)
]
hotfixes = [v for v in matching if "h" in v]
if not hotfixes:
return f"{base_v}h"
# Extract numeric suffix: h -> 1, h2 -> 2, etc.
nums = [int(h.split("h")[1]) if h.split("h")[1].isdigit() else 1 for h in hotfixes]
return f"{base_v}h{max(nums) + 1}"
def determine_retail_version(base_v, existing_versions, thaliak_version_new):
matching = [v for v in existing_versions if v["retail_version"].startswith(base_v)]
latest = matching[-1] if matching else None
# If we have a record for this base version but the Thaliak version is new, it's a hotfix.
if latest and thaliak_version_new and thaliak_version_new != latest["version_string"]:
return get_next_hotfix_version(base_v, existing_versions)
return latest["retail_version"] if latest else base_v
def is_maintenance_post(title): def is_maintenance_post(title):
"""Returns True if the title represents a primary world maintenance post.""" """Returns True if the title represents a primary world maintenance post."""
@@ -140,14 +162,7 @@ def is_maintenance_post(title):
return "all worlds" in t and "maintenance" in t and "follow-up" not in t return "all worlds" in t and "maintenance" in t and "follow-up" not in t
def format_retail_version(v, count): def scrape_latest_maintenance(thaliak_version_new=None):
"""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. Scrapes the Lodestone to find the most recent 'All Worlds Maintenance' post.
""" """
@@ -164,6 +179,7 @@ def scrape_latest_maintenance():
return None return None
news_items = parse_lodestone_news_list(html) news_items = parse_lodestone_news_list(html)
existing_versions = load_versions()
for item in news_items: for item in news_items:
title = item["title"] title = item["title"]
@@ -173,12 +189,18 @@ def scrape_latest_maintenance():
continue continue
detail_html = fetch_url(link) detail_html = fetch_url(link)
version = extract_patch_version(detail_html) if not detail_html:
continue
if version: base_version = extract_patch_version(detail_html)
# We assume the first (most recent) valid maintenance post if not base_version:
# with a patch version is our target. continue
return {"retail_version": version, "title": title, "url": link}
retail_version = determine_retail_version(
base_version, existing_versions, thaliak_version_new
)
return {"retail_version": retail_version, "title": title, "url": link}
return None return None
@@ -187,15 +209,15 @@ def get_version_info() -> Optional[dict]:
""" """
Returns the latest version information by combining Thaliak and Lodestone data. Returns the latest version information by combining Thaliak and Lodestone data.
""" """
date_new = fetch_latest_thaliak_patch() thaliak_version_new = fetch_latest_thaliak_patch()
maintenance = scrape_latest_maintenance() maintenance = scrape_latest_maintenance(thaliak_version_new=thaliak_version_new)
if not maintenance and not date_new: if not maintenance and not thaliak_version_new:
return None return None
return { return {
"retail_version": maintenance["retail_version"] if maintenance else "Unknown", "retail_version": maintenance["retail_version"] if maintenance else "Unknown",
"version_string": date_new, "version_string": thaliak_version_new,
"title": maintenance["title"] if maintenance else "Unknown", "title": maintenance["title"] if maintenance else "Unknown",
"url": maintenance["url"] if maintenance else "Unknown", "url": maintenance["url"] if maintenance else "Unknown",
} }
@@ -204,27 +226,26 @@ def get_version_info() -> Optional[dict]:
def get_patch_context(): def get_patch_context():
""" """
Returns a dictionary containing: Returns a dictionary containing:
- retail_prev, date_prev: The latest registered version. - retail_prev, thaliak_version_prev: The latest registered version.
- retail_new, date_new: The latest versions found externally. - retail_new, thaliak_version_new: The latest versions found externally.
- is_new: True if date_new is not in the registry. - is_new: True if thaliak_version_new is not in the registry.
""" """
versions = load_versions() versions = load_versions()
prev = versions[-1] if versions else None prev = versions[-1] if versions else None
date_new = fetch_latest_thaliak_patch() thaliak_version_new = fetch_latest_thaliak_patch()
maintenance = scrape_latest_maintenance(thaliak_version_new=thaliak_version_new)
maintenance = scrape_latest_maintenance()
retail_new = maintenance["retail_version"] if maintenance else None retail_new = maintenance["retail_version"] if maintenance else None
is_new = False is_new = thaliak_version_new is not None and not any(
if date_new: v["version_string"] == thaliak_version_new for v in versions
is_new = not any(v["version_string"] == date_new for v in versions) )
return { return {
"retail_prev": prev["retail_version"] if prev else None, "retail_prev": prev["retail_version"] if prev else None,
"date_prev": prev["version_string"] if prev else None, "thaliak_version_prev": prev["version_string"] if prev else None,
"retail_new": retail_new, "retail_new": retail_new,
"date_new": date_new, "thaliak_version_new": thaliak_version_new,
"is_new": is_new, "is_new": is_new,
} }
@@ -236,23 +257,26 @@ def update_and_get_info():
""" """
ctx = get_patch_context() ctx = get_patch_context()
if ctx["date_prev"]: if ctx["thaliak_version_prev"]:
print(f"date_prev={ctx['date_prev']}") print(f"thaliak_version_prev={ctx['thaliak_version_prev']}")
if ctx["retail_prev"]: if ctx["retail_prev"]:
print(f"retail_prev={ctx['retail_prev']}") print(f"retail_prev={ctx['retail_prev']}")
if ctx["date_new"]: if ctx["thaliak_version_new"]:
print(f"date_new={ctx['date_new']}") print(f"thaliak_version_new={ctx['thaliak_version_new']}")
if ctx["retail_new"]: if ctx["retail_new"]:
print(f"retail_new={ctx['retail_new']}") print(f"retail_new={ctx['retail_new']}")
print(f"is_new={'true' if ctx['is_new'] else 'false'}") print(f"is_new={'true' if ctx['is_new'] else 'false'}")
if ctx["is_new"] and ctx["date_new"] and ctx["retail_new"]: if ctx["is_new"] and ctx["thaliak_version_new"] and ctx["retail_new"]:
data = load_versions() data = load_versions()
data.append( data.append(
{"retail_version": ctx["retail_new"], "version_string": ctx["date_new"]} {
"retail_version": ctx["retail_new"],
"version_string": ctx["thaliak_version_new"],
}
) )
print( print(
f"Added new version mapping: {ctx['retail_new']} -> {ctx['date_new']}", f"Added new version mapping: {ctx['retail_new']} -> {ctx['thaliak_version_new']}",
file=sys.stderr, file=sys.stderr,
) )
with open(VERSIONS_FILE, "w") as f: with open(VERSIONS_FILE, "w") as f:
+16
View File
@@ -134,5 +134,21 @@
{ {
"retail_version": "7.45h", "retail_version": "7.45h",
"version_string": "2026.03.07.0000.0000" "version_string": "2026.03.07.0000.0000"
},
{
"retail_version": "7.45h2",
"version_string": "2026.03.17.0000.0000"
},
{
"retail_version": "7.50",
"version_string": "2026.04.21.0000.0000"
},
{
"retail_version": "7.50h",
"version_string": "2026.05.01.0000.0000"
},
{
"retail_version": "7.51",
"version_string": "2026.05.25.0000.0000"
} }
] ]
File diff suppressed because it is too large Load Diff
+6158
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6202
View File
File diff suppressed because it is too large Load Diff