120 lines
5.6 KiB
Python
120 lines
5.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Create a staged Lua bundle rewrite; never modify the source installation.
|
|
|
|
The caller supplies the Client's Lua/Windows directory, output directory and
|
|
two exact asset paths. This handles the observed LuaV big-endian index and
|
|
SRMI digest format only; it refuses ambiguous input instead of guessing.
|
|
"""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import struct
|
|
from pathlib import Path
|
|
|
|
|
|
def md5(data):
|
|
return hashlib.md5(data).digest()
|
|
|
|
|
|
def replace_once(buf, old, new):
|
|
if len(old) >= 128 or len(new) >= 128:
|
|
raise ValueError("only single-byte Lua string lengths are supported")
|
|
original = bytes([len(old)]) + old
|
|
replacement = bytes([len(new)]) + new
|
|
if buf.count(original) != 1:
|
|
raise ValueError("the exact length-prefixed string must occur once")
|
|
index = buf.index(original)
|
|
return buf[:index] + replacement + buf[index + len(original):], index
|
|
|
|
|
|
def stage(source, output, old, new):
|
|
source, output = source.resolve(strict=True), output.resolve()
|
|
if output == source or source in output.parents:
|
|
raise ValueError("output must be outside the source installation")
|
|
if output.exists() and any(output.iterdir()):
|
|
raise ValueError("output must be empty")
|
|
selector = (source / "M_LuaV.bytes").read_bytes()
|
|
if len(selector) != 66 or selector[:4] != b"SRMI":
|
|
raise ValueError("unsupported LuaV selector")
|
|
digest = b"".join(selector[i:i + 4][::-1] for i in range(28, 44, 4))
|
|
index_path = source / ("LuaV_" + digest.hex() + ".bytes")
|
|
index = bytearray(index_path.read_bytes())
|
|
if md5(index) != digest or struct.unpack_from("<I", selector, 44)[0] != len(index):
|
|
raise ValueError("LuaV selector digest or size does not match")
|
|
if len(index) < 16 or struct.unpack_from(">II", index, 0) != (255, 4):
|
|
raise ValueError("unsupported LuaV index header")
|
|
blocks, total_modules = struct.unpack_from(">II", index, 8)
|
|
pos, entries_seen, matches = 16, 0, []
|
|
old, new = old.encode("utf-8"), new.encode("utf-8")
|
|
for _ in range(blocks):
|
|
if pos + 36 > len(index):
|
|
raise ValueError("truncated LuaV block header")
|
|
block_digest = bytes(index[pos + 8:pos + 24])
|
|
reserved, size, count = struct.unpack_from(">III", index, pos + 24)
|
|
table = pos + 36
|
|
end = table + count * 16
|
|
if reserved != 0 or end > len(index):
|
|
raise ValueError("unsupported LuaV block table")
|
|
block_path = source / (block_digest.hex() + ".bytes")
|
|
data = block_path.read_bytes()
|
|
if len(data) != size or md5(data) != block_digest:
|
|
raise ValueError("Lua block digest or size differs")
|
|
entries_seen += count
|
|
needle = bytes([len(old)]) + old
|
|
if data.count(needle):
|
|
if data.count(needle) != 1:
|
|
raise ValueError("ambiguous path within Lua block")
|
|
location = data.index(needle)
|
|
entries = [struct.unpack_from(">QII", index, table + 16 * i) for i in range(count)]
|
|
containing = [(i, off, length) for i, (_, length, off) in enumerate(entries)
|
|
if off <= location and location + len(needle) <= off + length]
|
|
if len(containing) != 1:
|
|
raise ValueError("Lua module index does not uniquely cover the string")
|
|
matches.append((pos, table, data, entries, containing[0], location))
|
|
if bytes(index[end:end + 3]) != b"\x00\x00\x80":
|
|
raise ValueError("unsupported LuaV block trailer")
|
|
pos = end + 3
|
|
if entries_seen != total_modules or pos != len(index) or len(matches) != 1:
|
|
raise ValueError("LuaV index size/count or matching block differs")
|
|
pos, table, data, entries, (module, module_offset, module_len), location = matches[0]
|
|
rewritten, _ = replace_once(data, old, new)
|
|
delta = len(rewritten) - len(data)
|
|
for i, (_, length, offset) in enumerate(entries):
|
|
if i == module:
|
|
length += delta
|
|
elif offset >= module_offset + module_len:
|
|
offset += delta
|
|
elif offset < module_offset + module_len and offset + length > module_offset:
|
|
raise ValueError("overlapping Lua module entries")
|
|
if length < 0 or offset < 0 or offset + length > len(rewritten):
|
|
raise ValueError("rewritten Lua module bounds invalid")
|
|
struct.pack_into(">II", index, table + 16 * i + 8, length, offset)
|
|
index[pos + 8:pos + 24] = md5(rewritten)
|
|
struct.pack_into(">I", index, pos + 28, len(rewritten))
|
|
new_index_digest = md5(index)
|
|
updated_selector = bytearray(selector)
|
|
for i in range(4):
|
|
updated_selector[28 + i * 4:32 + i * 4] = new_index_digest[i * 4:i * 4 + 4][::-1]
|
|
output.mkdir(parents=True, exist_ok=True)
|
|
(output / (md5(rewritten).hex() + ".bytes")).write_bytes(rewritten)
|
|
(output / ("LuaV_" + new_index_digest.hex() + ".bytes")).write_bytes(index)
|
|
(output / "M_LuaV.bytes").write_bytes(updated_selector)
|
|
return {"old_block_md5": md5(data).hex(), "new_block_md5": md5(rewritten).hex(),
|
|
"old_index_md5": digest.hex(), "new_index_md5": new_index_digest.hex(),
|
|
"module_offset": module_offset, "module_old_size": module_len,
|
|
"module_new_size": module_len + delta, "string_offset": location}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("source", type=Path)
|
|
parser.add_argument("output", type=Path)
|
|
parser.add_argument("old_path")
|
|
parser.add_argument("new_path")
|
|
args = parser.parse_args()
|
|
print(json.dumps(stage(args.source, args.output, args.old_path, args.new_path), indent=2))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|