Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50f1d77ed2 | ||
|
|
8afa4ea447 | ||
|
|
b12a12cd93 | ||
|
|
0cc85b04a5 | ||
|
|
4dee3651e0 | ||
|
|
c165bc04a1 | ||
|
|
00f5cd3b8f | ||
|
|
b0cf3c3de0 | ||
|
|
56f70c606b | ||
|
|
4e396f1a44 | ||
|
|
61c8c5e7fc | ||
|
|
9aa680b6b1 | ||
|
|
c629407814 | ||
|
|
f705f99195 | ||
|
|
4a3094bd79 | ||
|
|
f44ec97a8d | ||
|
|
d4e943f31a | ||
|
|
28ea068169 | ||
|
|
555829c343 | ||
|
|
b01e2dc3b6 | ||
|
|
01017f29ff | ||
|
|
ceabd258b2 | ||
|
|
964e2e909d | ||
|
|
8bfa266661 | ||
|
|
4ba7cfaea1 | ||
|
|
1c3a2e2ffe | ||
|
|
0d851d2d4f | ||
|
|
65e6d9a197 |
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
+66
-42
@@ -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,7 +125,35 @@ 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)
|
||||||
return v + "0" if len(v.split(".")[1]) == 1 else v
|
if len(v.split(".")[1]) == 1:
|
||||||
|
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):
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -130,5 +130,49 @@
|
|||||||
{
|
{
|
||||||
"retail_version": "7.45",
|
"retail_version": "7.45",
|
||||||
"version_string": "2026.02.20.0000.0000"
|
"version_string": "2026.02.20.0000.0000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"retail_version": "7.45h",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"retail_version": "7.51h",
|
||||||
|
"version_string": "2026.06.10.0000.0000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"retail_version": "7.51h2",
|
||||||
|
"version_string": "2026.06.18.0000.0000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"retail_version": "7.55",
|
||||||
|
"version_string": "2026.07.16.0001.0000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"retail_version": "7.55h",
|
||||||
|
"version_string": "2026.08.05.0000.0000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"retail_version": "7.55h2",
|
||||||
|
"version_string": "2026.08.11.0000.0000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"retail_version": "7.56",
|
||||||
|
"version_string": "2026.09.01.0000.0000"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user