Files
Cesarus85--mjolnir-vr/tools/test_cache_file_formats.py
T
MrBruh ce41b41d78 List Custom Edition maps as custom singleplayer and multiplayer maps
Custom Edition maps are loaded from the custom_maps folder, as level
custom_maps\<name>, apart from the game's own maps of the same name. The
map lists get CUSTOM SINGLEPLAYER and CUSTOM MULTIPLAYER, by the scenario
type in the map: a custom singleplayer map plays as a campaign level, or
as network co-op from the Map screen; a custom multiplayer map plays as a
multiplayer map. A map with an OpenSauce header that asks for nothing of
OpenSauce's runs as stock Custom Edition runs it.
2026-10-07 22:40:09 +13:00

1450 lines
69 KiB
Python

"""Tests for loading Custom Edition caches, and refusing those that need
OpenSauce (port/linux/game/cache_file_formats.c).
The tests build complete but tiny Custom Edition caches and resource maps in
memory, so no game data is needed or stored, and run the report tool
port/tools/cache_file_report.c on them. The tool is compiled with clang,
warnings as errors and undefined behaviour trapping, so a crash or an
undefined operation on malformed input fails the test that caused it. The
module is also compiled as the game compiles it (gnu89).
When real maps are present (HALO_CUSTOM_EDITION_MAPS, or the gitignored
assets/custom_edition), test_real_maps_* also check the results recorded in
docs/custom_edition_caches.md.
"""
import os
from pathlib import Path
import random
import shutil
import struct
import subprocess
import sys
import zlib
import pytest
ROOT = Path(__file__).resolve().parents[1]
MODULE = ROOT / "port/linux/game/cache_file_formats.c"
TOOL = ROOT / "port/tools/cache_file_report.c"
BASE = 0x40440000
# a handle of the map a resource map was built with, which names nothing here
STALE_HANDLE = 0xE1AB0037
TAG_CACHE_BYTES = 0x01700000
HEADER_BYTES = 0x800
NONE = 0xFFFFFFFF
STRICT_FLAGS = ["-Wall", "-Wextra", "-Wpedantic", "-Werror"]
UB_TRAP_FLAGS = ["-fsanitize=undefined", "-fsanitize-trap=undefined"]
def code(text):
"""A four-character code as the files store it (MSVC multi-character value, little-endian)."""
return struct.unpack(">I", text.encode("latin-1"))[0]
# ---------- the report tool
def find_clang():
for candidate in (shutil.which("clang"), r"C:\Program Files\LLVM\bin\clang.exe"):
if candidate and Path(candidate).is_file():
return candidate
return None
def target_flag_sets():
if sys.platform == "win32":
return [["--target=i686-pc-windows-msvc", "-fuse-ld=lld", "-D_CRT_SECURE_NO_WARNINGS"]]
# the game is 32-bit; the module is written for any width
return [["-m32"], []]
@pytest.fixture(scope="session")
def report_tool(tmp_path_factory):
clang = find_clang()
if clang is None:
pytest.skip("clang is needed to build the report tool")
folder = tmp_path_factory.mktemp("cache-file-report")
output = folder / ("cache_file_report.exe" if sys.platform == "win32" else "cache_file_report")
errors = []
for target in target_flag_sets():
command = [clang, *target, "-std=c99", *STRICT_FLAGS, *UB_TRAP_FLAGS, "-O1", "-g",
f"-I{MODULE.parent}", str(MODULE), str(TOOL), "-o", str(output)]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode == 0:
return output
errors.append(result.stdout + result.stderr)
# a compile error is a failure, not a missing tool: report it
pytest.fail("could not build the report tool:\n" + "\n".join(errors))
def test_module_compiles_as_game_code_without_warnings(tmp_path):
"""The game compiles port/linux/game with -std=gnu89 -w; check the
warnings it hides."""
clang = find_clang()
if clang is None:
pytest.skip("clang is needed")
for target in target_flag_sets():
command = [clang, *target, "-std=gnu89", *STRICT_FLAGS, "-Wno-long-long", "-c", str(MODULE),
"-o", str(tmp_path / "cache_file_formats.o")]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode == 0:
return
pytest.fail(result.stdout + result.stderr)
def run_report(tool, *arguments):
result = subprocess.run([str(tool), *map(str, arguments)], capture_output=True, text=True)
# 0: everything loaded, 1: something did not; anything else is a crash
# or an undefined-behaviour trap
assert result.returncode in (0, 1), (result.returncode, result.stdout, result.stderr)
blocks = []
for chunk in result.stdout.split("\n\n"):
fields = {}
for line in chunk.splitlines():
key, _, value = line.partition(": ")
fields[key] = value
if fields.get("file"):
blocks.append(fields)
return result.returncode, blocks
def report_one(tool, path, *options):
returncode, blocks = run_report(tool, *options, path)
assert len(blocks) == 1
return returncode, blocks[0]
# ---------- synthetic files
class Blob:
"""Bytes that will sit at `base`, addressed by that base."""
def __init__(self, base=0):
self.base = base
self.bytes = bytearray()
def reserve(self, size, align=4):
while len(self.bytes) % align:
self.bytes.append(0)
address = self.base + len(self.bytes)
self.bytes.extend(bytes(size))
return address
def add(self, data, align=4):
address = self.reserve(len(data), align)
self.put(address, data)
return address
def put(self, address, data):
offset = address - self.base
self.bytes[offset:offset + len(data)] = data
def u16(self, address, value):
struct.pack_into("<H", self.bytes, address - self.base, value & 0xFFFF)
def u32(self, address, value):
struct.pack_into("<I", self.bytes, address - self.base, value & 0xFFFFFFFF)
def block(self, address, count, element_address):
self.u32(address, count)
self.u32(address + 4, element_address)
self.u32(address + 8, 0)
def data(self, address, size, flags=0, file_offset=0, data_address=0):
for index, value in enumerate((size, flags, file_offset, data_address, 0)):
self.u32(address + index * 4, value)
def resource_map_file(map_type, items):
"""A resource map: header, item data, names, index (OpenSauce s_data_file_header)."""
data = bytearray(16)
placed = []
for name, payload in items:
while len(data) % 4:
data.append(0)
placed.append((name, len(data), len(payload)))
data.extend(payload)
names_offset = len(data)
name_offsets = []
for name, _, _ in placed:
name_offsets.append(len(data) - names_offset)
data.extend(name.encode("latin-1") + b"\0")
index_offset = len(data)
for (name, offset, size), name_offset in zip(placed, name_offsets):
data.extend(struct.pack("<iii", name_offset, size, offset))
struct.pack_into("<iiii", data, 0, map_type, names_offset, index_offset, len(items))
return data, {name: offset for name, offset, _ in placed}
def bitmap_item(pixels_offset, pixels_size):
"""A bitmap tag as bitmaps.map holds it: addresses count from the item."""
item = Blob()
group = item.reserve(0x6C)
item.data(group + 0x1C, 0x40) # a compressed color plate the cache build left out
sequence = item.reserve(0x40)
sprite = item.reserve(0x20)
bitmap = item.reserve(0x30)
item.block(group + 0x54, 1, sequence)
item.block(sequence + 0x34, 1, sprite)
item.block(group + 0x60, 1, bitmap)
item.u32(bitmap, code("bitm"))
item.u16(bitmap + 0x0E, 1 << 8) # the pixels are in bitmaps.map
item.u32(bitmap + 0x18, pixels_offset)
item.u32(bitmap + 0x1C, pixels_size)
item.u32(bitmap + 0x20, STALE_HANDLE) # the tag in the map bitmaps.map was built with
item.u32(bitmap + 0x24, NONE)
return bytes(item.bytes)
SOUND_ENTRY_FIELDS = {"sample_rate": 1, "encoding": 1, "longest_permutation_length": 1234}
def sound_item(samples_offset, samples_size, pitch_ranges=1, compression=1):
"""A sound as sounds.map holds it: the header again, then pitch ranges and
permutations whose addresses count from the first pitch range. The
entry's header has the fields the map's copy leaves zero, and the
permutations the runtime fields of the map sounds.map was built with."""
header = bytearray(0xA4)
struct.pack_into("<H", header, 0x06, SOUND_ENTRY_FIELDS["sample_rate"])
struct.pack_into("<hh", header, 0x6C, SOUND_ENTRY_FIELDS["encoding"], compression)
struct.pack_into("<i", header, 0x84, SOUND_ENTRY_FIELDS["longest_permutation_length"])
struct.pack_into("<iII", header, 0x98, pitch_ranges, 0x5A1D818, 0xD39A3C) # stale editing-kit pointers
body = Blob()
ranges = [body.reserve(0x48) for _ in range(pitch_ranges)]
for pitch_range in ranges:
permutation = body.reserve(0x7C)
mouth = body.add(b"MOUTHDAT")
body.block(pitch_range + 0x3C, 1, permutation)
body.u16(permutation + 0x28, compression)
body.u32(permutation + 0x2C, 0x0BADF00D)
body.u32(permutation + 0x30, 0x0BADF00D)
body.u32(permutation + 0x34, STALE_HANDLE)
body.u32(permutation + 0x3C, STALE_HANDLE)
body.data(permutation + 0x40, samples_size, flags=1, file_offset=samples_offset)
body.data(permutation + 0x54, 8, data_address=mouth)
return bytes(header + body.bytes)
def string_list_item(strings=("hello", "world!")):
item = Blob()
group = item.reserve(0x0C)
references = item.reserve(len(strings) * 20)
item.block(group, len(strings), references)
for index, text in enumerate(strings):
encoded = text.encode("utf-16-le") + b"\0\0"
item.data(references + index * 20, len(encoded), data_address=item.add(encoded))
return bytes(item.bytes)
def font_item(style_reference=NONE):
item = Blob()
font = item.reserve(0x9C)
table = item.reserve(12)
entries = item.reserve(3 * 2)
characters = item.reserve(2 * 20)
pixels = item.add(bytes(range(8)))
item.block(font + 0x30, 1, table)
item.block(table, 3, entries)
item.block(font + 0x7C, 2, characters)
item.data(font + 0x88, 8, data_address=pixels)
for reference in range(4):
item.u32(font + 0x3C + reference * 16, code("font"))
item.u32(font + 0x3C + reference * 16 + 12, style_reference)
return bytes(item.bytes)
def hud_message_text_item():
item = Blob()
group = item.reserve(0x80)
text = item.add("text".encode("utf-16-le"))
elements = item.reserve(2 * 2)
messages = item.reserve(0x40)
item.data(group, 8, data_address=text)
item.block(group + 0x14, 2, elements)
item.block(group + 0x20, 1, messages)
return bytes(item.bytes)
GBXMODEL_PART = {"vertex_count": 3, "vertex_offset": 0, "strip_triangle_count": 1, "strip_offset": 0,
"triangle_type": 1, "vertex_type": 4, "local_node_count": 0}
def add_gbxmodel(blob, part_options):
"""A gbxmodel of one node and one geometry of one part, whose geometry
lies in the synthetic model data (vertices below 0x200, strips after)."""
options = dict(GBXMODEL_PART, **part_options)
model = blob.reserve(0xE8)
node = blob.reserve(0x9C)
geometry = blob.reserve(0x30)
part = blob.reserve(0x84)
shader = blob.reserve(0x20)
blob.block(model + 0xB8, 1, node)
blob.block(model + 0xD0, 1, geometry)
blob.block(model + 0xDC, 1, shader)
blob.block(geometry + 0x24, 1, part)
blob.u16(part + 0x44, options["triangle_type"])
blob.u32(part + 0x48, options["strip_triangle_count"])
blob.u32(part + 0x4C, options["strip_offset"])
blob.u16(part + 0x54, options["vertex_type"])
blob.u32(part + 0x58, options["vertex_count"])
blob.u32(part + 0x64, options["vertex_offset"])
blob.bytes[part - blob.base + 0x6B] = options["local_node_count"]
return model
# the type Custom Edition gives each shader group, and the one this build does
SHADER_TYPES = {"senv": (3, 3), "soso": (4, 4), "sotr": (5, 5), "schi": (6, 6), "scex": (7, 6),
"swat": (8, 7), "sgla": (9, 8), "smet": (10, 9), "spla": (11, 10)}
def add_shader(blob, group, options):
"""A shader of `group` with the type Custom Edition gives it; a chicago
extended shader with four-stage and two-stage maps and extra flags."""
size = {"scex": 0x78, "schi": 0x6C}.get(group, 0x28)
shader = blob.reserve(size)
blob.u16(shader + 0x24, options.get("type", SHADER_TYPES[group][0]))
if group == "scex":
four_stage = options.get("four_stage", 2)
two_stage = options.get("two_stage", 1)
blob.block(shader + 0x54, four_stage, blob.reserve(0xDC * four_stage) if four_stage else 0)
blob.block(shader + 0x60, two_stage, blob.reserve(0xDC * two_stage) if two_stage else 0)
blob.u32(shader + 0x6C, options.get("extra_flags", 0x5))
return shader
def add_hud_placement(blob, placement, scale, flags):
struct.pack_into("<ff", blob.bytes, placement + 4 - blob.base, *scale)
blob.u16(placement + 0x0C, flags)
def add_weapon_hud(blob, placements, bitmap_index=None):
"""A weapon HUD interface with a static element for each of the first two
placements and a crosshair of one item for the third, each (scale, flags);
the first static and the crosshair draw the bitmap of tag bitmap_index."""
hud = blob.reserve(0x17C)
statics = blob.reserve(2 * 0xB4)
crosshairs = blob.reserve(0x68)
items = blob.reserve(0x6C)
blob.block(hud + 0x60, 2, statics)
blob.block(hud + 0x84, 1, crosshairs)
blob.block(crosshairs + 0x34, 1, items)
add_hud_placement(blob, statics + 0x24, *placements[0])
add_hud_placement(blob, statics + 0xB4 + 0x24, *placements[1])
add_hud_placement(blob, items, *placements[2])
if bitmap_index is not None:
blob.u32(statics + 0x48 + 0x0C, bitmap_index)
blob.u32(crosshairs + 0x24 + 0x0C, bitmap_index)
return hud
def add_animation_graph(blob, overlay_animation_index):
"""An animation graph of one animation and one object overlay."""
graph = blob.reserve(0x80)
overlay = blob.reserve(0x14)
animation = blob.reserve(0xB4)
blob.block(graph + 0x00, 1, overlay)
blob.block(graph + 0x74, 1, animation)
blob.u16(overlay, overlay_animation_index)
return graph
BSP_MATERIAL = {"vertex_count": 3, "lightmap_vertex_count": 3, "bitmap_index": 0, "vertex_type": 0,
"vertices_size": None, "vertices_offset": 0x3C0, "normal": (0.0, 0.0, 1.0)}
BSP_MATERIAL_OFFSET = 0x2C0
def structure_bsp_bytes(size, address, material=None):
"""A structure BSP: its header, and a structure with one lightmap of one
material when `material` gives that material's options."""
bsp = bytearray(size)
struct.pack_into("<IiIiII", bsp, 0, address + 0x18, 0, 0, 0, 0, code("sbsp"))
if material is not None:
options = dict(BSP_MATERIAL, **material)
structure, lightmap, element = 0x18, 0x2A0, BSP_MATERIAL_OFFSET
vertex_count, lightmap_vertex_count = options["vertex_count"], options["lightmap_vertex_count"]
vertices_size = options["vertices_size"]
if vertices_size is None:
vertices_size = vertex_count * 56 + lightmap_vertex_count * 20
struct.pack_into("<iII", bsp, structure + 0x104, 1, address + lightmap, 0)
struct.pack_into("<h", bsp, lightmap, options["bitmap_index"])
struct.pack_into("<iII", bsp, lightmap + 0x14, 1, address + element, 0)
struct.pack_into("<h", bsp, element + 0xB0, options["vertex_type"])
struct.pack_into("<i", bsp, element + 0xB4, vertex_count)
struct.pack_into("<i", bsp, element + 0xC8, lightmap_vertex_count)
struct.pack_into("<iIIII", bsp, element + 0xD8, vertices_size, 0, 0, address + options["vertices_offset"], 0)
if options["vertices_offset"] + vertices_size > size:
return bsp # the vertices say they lie beyond the BSP
for vertex_index in range(vertex_count):
vertex = options["vertices_offset"] + vertex_index * 56
struct.pack_into("<9f", bsp, vertex + 12, *options["normal"], 1.0, 0.0, 0.0, 0.0, 1.0, 0.0)
for vertex_index in range(lightmap_vertex_count):
vertex = options["vertices_offset"] + vertex_count * 56 + vertex_index * 20
struct.pack_into("<3f", bsp, vertex, 0.0, 0.0, 1.0)
return bsp
class Map:
"""A complete, minimal Custom Edition cache and its resource maps.
Every option changes one thing from the valid default, and `where`
records the file offsets of the fields tests corrupt afterwards."""
def __init__(self, opensauce_flags=None, trailing=b"",
extra_tags=(), bsp_gap=None, bitmap_pixels_size=16, sound_samples_size=32,
font_style_reference=NONE, pitch_ranges=1, bsp_sizes=(0x1000,), sound_compression=1,
model=None, bsp_material=None, shaders=(), animation_overlay=None,
weapon_hud=None, hud_bitmap_flags=None, in_map_bitmap=None, strings_name="test\\strings",
strings=("hello", "world!"), name=b"test", tags_checksum=0):
self.bsp_sizes = bsp_sizes
# the header's name and the tag data's checksum, by which Chimera's
# map list knows a map
self.name = name
self.tags_checksum = tags_checksum
self.sound_compression = sound_compression
# opt-in tags and content, so the defaults above keep their counts
self.model = model
self.bsp_material = bsp_material
self.shaders = shaders
self.animation_overlay = animation_overlay
self.weapon_hud = weapon_hud
self.hud_bitmap_flags = hud_bitmap_flags
# (width, height, type, format, flags) of the bitmap kept in the map
self.in_map_bitmap = in_map_bitmap
self.strings_name = strings_name
self.strings = strings
self.addresses = {}
# OpenSauce's header (its signature, version 1 and these flags) where a
# Custom Edition cache has padding, when not None
self.opensauce_flags = opensauce_flags
self.trailing = trailing
self.extra_tags = extra_tags
self.bsp_gap = bsp_gap
self.bitmap_pixels_size = bitmap_pixels_size
self.sound_samples_size = sound_samples_size
self.font_style_reference = font_style_reference
self.pitch_ranges = pitch_ranges
self.where = {}
def resource_maps(self):
# each map's first item sits right after its 16-byte header
pixels_offset = 16
bitmaps, _ = resource_map_file(1, [
("test\\external bitmap__pixels", bytes(range(16))),
("test\\external bitmap", bitmap_item(pixels_offset, self.bitmap_pixels_size)),
])
samples_offset = 16
sounds, _ = resource_map_file(2, [
("test\\sound__permutations", bytes(32)),
("test\\sound", sound_item(samples_offset, self.sound_samples_size, self.pitch_ranges,
self.sound_compression)),
])
loc, _ = resource_map_file(3, [
(self.strings_name, string_list_item(self.strings)),
("test\\font", font_item(self.font_style_reference)),
("test\\hud messages", hud_message_text_item()),
])
return {"bitmaps": bitmaps, "sounds": sounds, "loc": loc}
def build(self):
tags = [
("scnr", "test\\scenario", False),
("sbsp", "test\\bsp", False),
("bitm", "test\\in map bitmap", False),
("bitm", "test\\external bitmap", True),
("snd!", "test\\sound", True),
("ustr", self.strings_name, True),
("font", "test\\font", True),
("hmt ", "test\\hud messages", True),
("weap", "test\\weapon", False),
] + list(self.extra_tags)
if self.model is not None:
tags.append(("mod2", "test\\model", False))
tags += [(group, f"test\\shader {index}", False) for index, (group, _) in enumerate(self.shaders)]
if self.animation_overlay is not None:
tags.append(("antr", "test\\animations", False))
if self.weapon_hud is not None:
tags.append(("wphi", "test\\weapon hud", False))
# further structure BSPs go last, so the indices above never move
bsp_tag_indices = [1] + [len(tags) + extra for extra in range(len(self.bsp_sizes) - 1)]
tags += [("sbsp", f"test\\bsp {extra + 2}", False) for extra in range(len(self.bsp_sizes) - 1)]
resource_indices = {"test\\external bitmap": 1, self.strings_name: 0, "test\\font": 1,
"test\\hud messages": 2}
salt = 0xE174
# the file before the tag data: header, structure BSPs one after
# another, model data, pixels
model_data = bytes(range(256)) * 4
in_map_pixels = bytes(range(32))
bsp_offset = HEADER_BYTES
model_offset = bsp_offset + sum(self.bsp_sizes)
pixels_offset = model_offset + len(model_data)
tag_data_offset = pixels_offset + len(in_map_pixels)
if self.bsp_gap is None:
bsp_addresses = [BASE + TAG_CACHE_BYTES - size for size in self.bsp_sizes]
else:
bsp_addresses = [BASE + self.bsp_gap for _ in self.bsp_sizes]
tag_data = Blob(BASE)
index = tag_data.reserve(0x28)
instances = tag_data.reserve(len(tags) * 0x20)
tag_data.u32(index + 0x00, instances)
tag_data.u32(index + 0x04, salt << 16)
tag_data.u32(index + 0x08, self.tags_checksum)
tag_data.u32(index + 0x0C, len(tags))
tag_data.u32(index + 0x10, 3)
tag_data.u32(index + 0x14, model_offset)
tag_data.u32(index + 0x18, 3)
tag_data.u32(index + 0x1C, 0x200)
tag_data.u32(index + 0x20, len(model_data))
tag_data.u32(index + 0x24, code("tags"))
for tag_index, (group, name, external) in enumerate(tags):
instance = instances + tag_index * 0x20
tag_data.u32(instance, code(group))
tag_data.u32(instance + 0x04, NONE)
tag_data.u32(instance + 0x08, NONE)
tag_data.u32(instance + 0x0C, ((salt + tag_index) << 16) | tag_index)
tag_data.u32(instance + 0x10, tag_data.add(name.encode("latin-1") + b"\0", align=1))
tag_data.u32(instance + 0x18, 1 if external else 0)
if external and group != "snd!":
tag_data.u32(instance + 0x14, resource_indices[name])
address_of = {}
# the scenario and its structure BSP references
scenario = tag_data.reserve(0x5B0)
reference = tag_data.reserve(0x20 * len(self.bsp_sizes))
tag_data.block(scenario + 0x5A4, len(self.bsp_sizes), reference)
file_offset = bsp_offset
for bsp_index, size in enumerate(self.bsp_sizes):
element = reference + bsp_index * 0x20
tag_index = bsp_tag_indices[bsp_index]
tag_data.u32(element + 0x00, file_offset)
tag_data.u32(element + 0x04, size)
tag_data.u32(element + 0x08, bsp_addresses[bsp_index])
tag_data.u32(element + 0x10, code("sbsp"))
tag_data.u32(element + 0x1C, ((salt + tag_index) << 16) | tag_index)
file_offset += size
address_of["test\\scenario"] = scenario
# a bitmap kept in the map, its pixels in the map too
group = tag_data.reserve(0x6C)
bitmap = tag_data.reserve(0x30)
tag_data.block(group + 0x60, 1, bitmap)
tag_data.u32(bitmap + 0x18, pixels_offset)
tag_data.u32(bitmap + 0x1C, len(in_map_pixels))
if self.in_map_bitmap is not None:
width, height, kind, bitmap_format, flags = self.in_map_bitmap
tag_data.u16(bitmap + 0x04, width)
tag_data.u16(bitmap + 0x06, height)
tag_data.u16(bitmap + 0x0A, kind)
tag_data.u16(bitmap + 0x0C, bitmap_format)
tag_data.u16(bitmap + 0x0E, flags)
address_of["in map bitmap data"] = bitmap
address_of["test\\in map bitmap"] = group
# the header the map keeps of a sound held by sounds.map
sound = tag_data.reserve(0xA4)
tag_data.u32(sound + 0x98, self.pitch_ranges)
address_of["test\\sound"] = sound
address_of["test\\weapon"] = tag_data.add(bytes(0x100))
for group_name, name, external in self.extra_tags:
address_of[name] = tag_data.add(bytes(0x40))
if self.model is not None:
address_of["test\\model"] = add_gbxmodel(tag_data, self.model)
for index, (group, options) in enumerate(self.shaders):
address_of[f"test\\shader {index}"] = add_shader(tag_data, group, options)
if self.animation_overlay is not None:
address_of["test\\animations"] = add_animation_graph(tag_data, self.animation_overlay)
if self.weapon_hud is not None:
hud_bitmap_index = None
if self.hud_bitmap_flags is not None:
tag_data.u16(address_of["test\\in map bitmap"] + 6, self.hud_bitmap_flags)
hud_bitmap_index = [name for _, name, _ in tags].index("test\\in map bitmap")
address_of["test\\weapon hud"] = add_weapon_hud(tag_data, self.weapon_hud, hud_bitmap_index)
for tag_index, (group_name, name, external) in enumerate(tags):
if name in address_of:
tag_data.u32(instances + tag_index * 0x20 + 0x14, address_of[name])
# the file
data = bytearray(HEADER_BYTES)
for bsp_index, (size, address) in enumerate(zip(self.bsp_sizes, bsp_addresses)):
data += structure_bsp_bytes(size, address, self.bsp_material if bsp_index == 0 else None)
data += model_data + in_map_pixels + tag_data.bytes
file_length = len(data)
data += self.trailing
header = data
struct.pack_into("<IiIIII", header, 0, code("head"), 609, file_length, 0,
tag_data_offset, len(tag_data.bytes))
header[0x20:0x20 + len(self.name) + 1] = self.name + b"\0"
header[0x40:0x40 + 14] = b"01.00.00.0609\0"
struct.pack_into("<h", header, 0x60, 1)
struct.pack_into("<I", header, 0x7FC, code("foot"))
if self.opensauce_flags is not None:
struct.pack_into("<IhH", header, 0x70, code("yelo"), 1, self.opensauce_flags)
checksum = zlib.crc32(bytes(data[bsp_offset:bsp_offset + sum(self.bsp_sizes)]))
checksum = zlib.crc32(model_data, checksum)
checksum = zlib.crc32(bytes(tag_data.bytes), checksum)
struct.pack_into("<I", header, 0x64, ~checksum & 0xFFFFFFFF)
self.where = {
"tag_data": tag_data_offset,
"index": tag_data_offset,
"instances": tag_data_offset + (instances - BASE),
"scenario": tag_data_offset + (scenario - BASE),
"reference": tag_data_offset + (reference - BASE),
"bsp": bsp_offset,
"in_map_bitmap": tag_data_offset + (bitmap - BASE),
"sound_header": tag_data_offset + (sound - BASE),
"tag_count": len(tags),
"bsp_material": bsp_offset + BSP_MATERIAL_OFFSET,
}
if self.model is not None:
self.where["model"] = tag_data_offset + (address_of["test\\model"] - BASE)
self.addresses = dict(address_of, instances=instances)
self.tag_indices = {name: index for index, (_, name, _) in enumerate(tags)}
self.salt = salt
return bytes(data)
def write(self, folder, name="test.map", resource_maps=True):
folder.mkdir(parents=True, exist_ok=True)
path = folder / name
path.write_bytes(self.build())
if resource_maps:
for type_name, data in self.resource_maps().items():
(folder / f"{type_name}.map").write_bytes(data)
return path
def patched(path, offset, fmt, value):
data = bytearray(path.read_bytes())
struct.pack_into(fmt, data, offset, value)
path.write_bytes(data)
return path
# ---------- supported formats
def test_minimal_custom_edition_cache_loads_with_every_resource(report_tool, tmp_path):
path = Map().write(tmp_path)
returncode, report = report_one(report_tool, path)
assert returncode == 0
assert report["format"] == "Custom Edition cache"
assert report["version"] == "609" and report["build"] == "01.00.00.0609" and report["name"] == "test"
assert report["load"] == "ok"
assert report["tag_cache_bytes"] == hex(TAG_CACHE_BYTES)
assert report["tags"] == "9" and report["scenario_tag"] == "0"
assert report["resource_tags"] == "bitmaps 1, sounds 1, loc 3"
# the in-map bitmap and the resource bitmap; one permutation's samples
assert report["bitmap_data_ranges_checked"] == "2"
assert report["sound_sample_ranges_checked"] == "1"
assert report["computed_checksum"] == report["checksum"]
assert report["warnings"] == "none"
assert report["structure_bsps"] == "1"
assert report["lowest_structure_bsp_address"] == hex(BASE + TAG_CACHE_BYTES - 0x1000)
assert report["milestone.recognize"] == "yes"
assert report["milestone.load"] == "yes"
# the tool loads and converts what it can from the bytes alone; running
# takes the game (docs/custom_edition_caches.md)
assert report["milestone.run"].startswith("not observed by this tool")
assert report["convert"] == "ok"
def test_relocation_counts_every_resource_pointer(report_tool, tmp_path):
"""bitmap: sequences, sprites, bitmaps (3); sound: pitch ranges,
permutations, mouth data (3); strings: block + 2 strings (3); font:
tables, table entries, characters, pixels (4); HUD text: text, 2 blocks (3)"""
_, report = report_one(report_tool, Map().write(tmp_path))
assert report["relocated_pointers"] == "16"
def test_several_pitch_ranges_are_relocated(report_tool, tmp_path):
_, report = report_one(report_tool, Map(pitch_ranges=3).write(tmp_path))
assert report["load"] == "ok"
assert report["sound_sample_ranges_checked"] == "3"
def test_a_protected_scenario_is_given_the_scenario_group(report_tool, tmp_path):
"""Map protection renames the scenario tag's group (to 'prot', say). Halo PC
used the tag the header names regardless, so the loader gives it the
scenario's group and loads the map."""
cache = Map()
path = cache.write(tmp_path)
instance_case(0, 0x00, "<I", code("prot"))(cache, path)
returncode, report = report_one(report_tool, path)
assert returncode == 0
assert report["load"] == "ok"
assert report["scenario_regrouped"] == "1"
def test_a_structure_bsp_instance_with_its_load_address_loads(report_tool, tmp_path):
"""Invader writes the address a structure BSP loads at in its tag
instance (cursed-damnation); it has none until it is loaded."""
cache = Map()
path = instance_case(1, 0x14, "<I", BASE + 0x1500000)(cache, cache.write(tmp_path))
returncode, report = report_one(report_tool, path)
assert returncode == 0 and report["load"] == "ok"
def test_several_structure_bsps_share_the_top_of_the_tag_cache(report_tool, tmp_path):
"""Each BSP is loaded alone at the top of the tag cache; the largest sets
how much room is left for the tags, and the checksum covers them all,
packed one after another (none of the sample maps has more than one)."""
returncode, report = report_one(report_tool, Map(bsp_sizes=(0x1000, 0x3000, 0x2000)).write(tmp_path))
assert returncode == 0
assert report["structure_bsps"] == "3"
assert report["largest_structure_bsp_bytes"] == "0x3000"
assert report["lowest_structure_bsp_address"] == hex(BASE + TAG_CACHE_BYTES - 0x3000)
assert report["computed_checksum"] == report["checksum"]
assert report["warnings"] == "none"
def test_resource_maps_are_recognized_by_type(report_tool, tmp_path):
Map().write(tmp_path)
returncode, blocks = run_report(report_tool, tmp_path / "bitmaps.map", tmp_path / "sounds.map",
tmp_path / "loc.map")
assert returncode == 0
assert [(b["format"], b["resource_map_type"], b["resource_map"]) for b in blocks] == [
("Custom Edition resource map", "bitmaps", "ok"),
("Custom Edition resource map", "sounds", "ok"),
("Custom Edition resource map", "loc", "ok"),
]
assert [b["resource_items"] for b in blocks] == ["2", "2", "3"]
def test_trailing_data_is_reported_not_rejected(report_tool, tmp_path):
path = Map(trailing=b"\xAA" * 100).write(tmp_path)
returncode, report = report_one(report_tool, path)
assert returncode == 0
assert report["trailing_bytes"] == "0x64"
assert report["warnings"] == "trailing data"
def test_checksum_mismatch_is_reported_not_rejected(report_tool, tmp_path):
path = patched(Map().write(tmp_path), 0x64, "<I", 0x12345678)
returncode, report = report_one(report_tool, path)
assert returncode == 0
assert report["load"] == "ok"
assert report["warnings"] == "checksum mismatch"
OPENSAUCE_REFUSED = ("an OpenSauce map that needs OpenSauce (its header asks for memory upgrades, mod data files or "
"the like), which this build does not run")
@pytest.mark.parametrize("flags", [1, 2, 4, 1 << 15])
def test_caches_that_need_opensauce_are_refused(report_tool, tmp_path, flags):
"""A cache whose OpenSauce header asks for anything of OpenSauce's
(memory upgrades, mod data files, any flag) is refused as it is
identified."""
path = Map(opensauce_flags=flags).write(tmp_path)
returncode, report = report_one(report_tool, path)
assert returncode == 1
assert report["identify"] == OPENSAUCE_REFUSED
assert "load" not in report
def test_caches_that_only_carry_opensauce_data_load(report_tool, tmp_path):
"""A cache with OpenSauce's header asking for nothing, and its
project_yellow and project_yellow_globals tags, runs as stock Custom
Edition runs it: neither is read (SPV3's backwards-compatible a50.map)."""
path = Map(opensauce_flags=0, extra_tags=[("yelo", "test\\project yellow", False),
("gelo", "test\\project yellow globals", False)]).write(tmp_path)
returncode, report = report_one(report_tool, path)
assert returncode == 0
assert report["identify"] == "ok" and report["load"] == "ok"
def test_resource_maps_are_found_in_the_maps_directory_option(report_tool, tmp_path):
Map().write(tmp_path / "maps")
moved = tmp_path / "elsewhere" / "test.map"
moved.parent.mkdir()
(tmp_path / "maps" / "test.map").rename(moved)
returncode, report = report_one(report_tool, moved, "--maps", tmp_path / "maps")
assert returncode == 0 and report["load"] == "ok"
# ---------- geometry checked at load, and conversion for this build
def converted(report_tool, cache, folder):
"""Loads and converts `cache`, returning the report and the converted tags
as they would sit at BASE (empty when nothing was converted)."""
path = cache.write(folder)
dump = folder / "converted_tags.bin"
returncode, report = report_one(report_tool, path, "--dump-tags", dump)
return returncode, report, dump.read_bytes() if dump.is_file() else b""
def u16_at(tags, address):
return struct.unpack_from("<H", tags, address - BASE)[0]
def s16_at(tags, address):
return struct.unpack_from("<h", tags, address - BASE)[0]
def u32_at(tags, address):
return struct.unpack_from("<I", tags, address - BASE)[0]
def test_valid_geometry_loads_and_is_counted(report_tool, tmp_path):
returncode, report, _ = converted(report_tool, Map(model={}, bsp_material={}), tmp_path)
assert returncode == 0
assert report["load"] == "ok" and report["convert"] == "ok"
assert report["structure_bsp_materials_checked"] == "1"
assert report["model_data"] == f"0x400 bytes at {hex(HEADER_BYTES + 0x1000)}, strips from 0x200"
def test_a_material_without_a_lightmap_has_no_lightmap_vertices(report_tool, tmp_path):
material = {"bitmap_index": -1, "lightmap_vertex_count": 0}
returncode, report, _ = converted(report_tool, Map(bsp_material=material), tmp_path)
assert returncode == 0 and report["structure_bsp_materials_checked"] == "1"
BAD_MODEL_PART = "a model part's strip or vertices lie outside the model data or are not of the kind Custom Edition writes"
MALFORMED_MODEL_PARTS = {
"vertices beyond the vertex data": {"vertex_offset": 0x1C0},
"vertices unaligned": {"vertex_offset": 2},
"no vertices": {"vertex_count": 0},
"more vertices than strips can index": {"vertex_count": 0x10000},
"strip beyond the index data": {"strip_offset": 0x1FE},
"strip unaligned": {"strip_offset": 1},
"empty strip": {"strip_triangle_count": 0},
"huge strip": {"strip_triangle_count": 0x7FFFFFFF},
"not a strip": {"triangle_type": 0},
"compressed vertices": {"vertex_type": 5},
"too many local nodes": {"local_node_count": 23},
}
@pytest.mark.parametrize("case", sorted(MALFORMED_MODEL_PARTS))
def test_malformed_model_parts_are_rejected(report_tool, tmp_path, case):
returncode, report, _ = converted(report_tool, Map(model=MALFORMED_MODEL_PARTS[case]), tmp_path)
assert returncode == 1
assert report["load"] == BAD_MODEL_PART
def test_model_blocks_outside_the_tags_are_rejected(report_tool, tmp_path):
cache = Map(model={})
path = patched(cache.write(tmp_path), cache.where["model"] + 0xD4, "<I", BASE + 0x1000000)
returncode, report = report_one(report_tool, path)
assert returncode == 1
assert report["load"] == "a tag block or tag data field lies outside the loaded tags"
BAD_BSP_GEOMETRY = "a structure BSP's lightmaps or their materials do not have the documented layout"
MALFORMED_BSP_MATERIALS = {
"compressed vertices": {"vertex_type": 1},
"vertex data of the wrong size": {"vertices_size": 3 * 56},
"lightmap vertices without a lightmap": {"bitmap_index": -1},
"no lightmap vertices with a lightmap": {"lightmap_vertex_count": 0},
"vertices beyond the BSP": {"vertices_offset": 0xF80},
"vertices unaligned": {"vertices_offset": 0x3C2},
"negative vertex count": {"vertex_count": -1, "lightmap_vertex_count": -1, "vertices_size": 0},
"normal too long": {"normal": (0.0, 0.0, 1.5)},
"normal not a number": {"normal": (0.0, float("nan"), 1.0)},
}
@pytest.mark.parametrize("case", sorted(MALFORMED_BSP_MATERIALS))
def test_malformed_structure_bsp_materials_are_rejected(report_tool, tmp_path, case):
returncode, report, _ = converted(report_tool, Map(bsp_material=MALFORMED_BSP_MATERIALS[case]), tmp_path)
assert returncode == 1
assert report["load"] == BAD_BSP_GEOMETRY
def test_structure_bsp_lightmaps_outside_the_bsp_are_rejected(report_tool, tmp_path):
cache = Map(bsp_material={})
path = patched(cache.write(tmp_path), cache.where["bsp"] + 0x18 + 0x108, "<I", BASE)
_, report = report_one(report_tool, path)
assert report["load"] == BAD_BSP_GEOMETRY
def test_shaders_are_renumbered_as_this_build_numbers_them(report_tool, tmp_path):
groups = sorted(SHADER_TYPES)
cache = Map(shaders=[(group, {}) for group in groups])
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["convert"] == "ok"
for index, group in enumerate(groups):
shader = cache.addresses[f"test\\shader {index}"]
assert s16_at(tags, shader + 0x24) == SHADER_TYPES[group][1], group
assert report["shaders_renumbered"] == "5" # scex, swat, sgla, smet, spla
def test_chicago_extended_shaders_become_chicago_shaders(report_tool, tmp_path):
cache = Map(shaders=[("scex", {"four_stage": 2, "two_stage": 1, "extra_flags": 0x5}),
("scex", {"four_stage": 0, "two_stage": 1, "extra_flags": 0x2})])
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0
assert report["chicago_extended_shaders_converted"] == "2"
for index, (maps_count, extra_flags) in enumerate([(2, 0x5), (1, 0x2)]):
shader = cache.addresses[f"test\\shader {index}"]
instance = cache.addresses["instances"] + cache.tag_indices[f"test\\shader {index}"] * 0x20
assert u32_at(tags, instance) == code("schi")
assert u32_at(tags, instance + 0x04) == NONE # the parent groups stay
# the four-stage maps, or the two-stage ones when there are no others
assert u32_at(tags, shader + 0x54) == maps_count
assert u32_at(tags, shader + 0x60) == extra_flags
assert tags[shader - BASE + 0x64:shader - BASE + 0x6C] == bytes(8)
def test_a_shader_with_another_groups_type_is_given_its_groups(report_tool, tmp_path):
cache = Map(shaders=[("swat", {"type": 7})])
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0
assert report["shaders_mistyped"] == "1"
shader = cache.addresses["test\\shader 0"]
assert u16_at(tags, shader + 0x24) == SHADER_TYPES["swat"][1]
@pytest.mark.parametrize("bitmap, made_linear, flags", [
((3840, 64, 0, 11, 0x81), 1, 0x90), # birdcage's needler plasma: linear, no longer "power of two"
((256, 64, 0, 11, 0x01), 0, 0x01), # powers of two: swizzled as before
((96, 96, 0, 14, 0x03), 0, 0x03), # DXT1: linear ones cannot be compressed, so it stays
((96, 96, 0, 17, 0x04), 0, 0x04), # P8: nor palettized
((96, 96, 2, 11, 0x00), 0, 0x00), # a cube map: only 2D bitmaps can be linear
((100, 50, 0, 11, 0x10), 0, 0x10), # linear already
], ids=["npot", "power of two", "dxt1", "p8", "cube map", "linear"])
def test_bitmaps_of_sides_only_halo_pc_draws_are_made_linear(report_tool, tmp_path, bitmap, made_linear, flags):
cache = Map(in_map_bitmap=bitmap)
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["bitmaps_made_linear"] == str(made_linear)
assert u16_at(tags, cache.addresses["in map bitmap data"] + 0x0E) == flags
def test_bitmaps_name_their_own_tag(report_tool, tmp_path):
cache = Map()
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["bitmaps_prepared"] == "2"
for name in ("test\\in map bitmap", "test\\external bitmap"):
index = cache.tag_indices[name]
group = u32_at(tags, cache.addresses["instances"] + index * 0x20 + 0x14)
bitmap = u32_at(tags, group + 0x64)
assert u32_at(tags, bitmap + 0x20) == ((cache.salt + index) << 16 | index), name
assert u32_at(tags, bitmap + 0x24) == NONE
assert u32_at(tags, bitmap + 0x28) == 0 and u32_at(tags, bitmap + 0x2C) == 0
def sound_parts(tags, cache):
"""The sound's header, and its first permutation (after conversion)."""
header = cache.addresses["test\\sound"]
pitch_ranges = u32_at(tags, header + 0x9C)
permutation = u32_at(tags, pitch_ranges + 0x40) if pitch_ranges else None
return header, permutation
def test_sounds_take_what_the_maps_copy_lacks_from_sounds_map(report_tool, tmp_path):
cache = Map()
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["sounds_undecodable"] == "0"
header, permutation = sound_parts(tags, cache)
assert u16_at(tags, header + 0x06) == SOUND_ENTRY_FIELDS["sample_rate"]
assert s16_at(tags, header + 0x6C) == SOUND_ENTRY_FIELDS["encoding"]
assert s16_at(tags, header + 0x6E) == 1
assert u32_at(tags, header + 0x84) == SOUND_ENTRY_FIELDS["longest_permutation_length"]
# its permutations name the sound itself, and have no cache block or samples
index = cache.tag_indices["test\\sound"]
handle = (cache.salt + index) << 16 | index
assert [u32_at(tags, permutation + offset) for offset in (0x2C, 0x30, 0x34, 0x3C)] == [NONE, 0, handle, handle]
def test_sounds_this_build_cannot_decode_are_made_unplayable(report_tool, tmp_path):
cache = Map(sound_compression=3) # Ogg Vorbis
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0
assert report["sounds_undecodable"] == "1"
assert u32_at(tags, cache.addresses["test\\sound"] + 0x98) == 0
def test_animation_overlays_naming_missing_animations_are_disabled(report_tool, tmp_path):
cache = Map(animation_overlay=1) # the graph has one animation
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["animation_overlays_disabled"] == "1"
overlay = u32_at(tags, cache.addresses["test\\animations"] + 0x04)
assert s16_at(tags, overlay) == -1
def test_animation_overlays_naming_real_animations_are_kept(report_tool, tmp_path):
cache = Map(animation_overlay=0)
_, report, tags = converted(report_tool, cache, tmp_path)
assert report["animation_overlays_disabled"] == "0"
assert s16_at(tags, u32_at(tags, cache.addresses["test\\animations"] + 0x04)) == 0
def f32_at(tags, address):
return struct.unpack_from("<f", tags, address - BASE)[0]
def test_hud_elements_with_the_high_resolution_scale_are_halved(report_tool, tmp_path):
"""Halo PC draws them from bitmaps at twice the Xbox's size; this build
ignores the flag, so the scale takes it in and the flag goes. Halo PC
reads it only on statics, meters and numbers: a crosshair's item keeps
its scale and flag."""
cache = Map(weapon_hud=[((1.0, 0.5), 4 | 1), ((1.0, 1.0), 1), ((2.0, 2.0), 4)])
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["hud_placements_rescaled"] == "1"
hud = cache.addresses["test\\weapon hud"]
statics = u32_at(tags, hud + 0x64)
items = u32_at(tags, u32_at(tags, hud + 0x88) + 0x38)
assert (f32_at(tags, statics + 0x28), f32_at(tags, statics + 0x2C), u16_at(tags, statics + 0x30)) == (0.5, 0.25, 1)
assert (f32_at(tags, items + 4), f32_at(tags, items + 8), u16_at(tags, items + 0x0C)) == (2.0, 2.0, 4)
def test_hud_elements_without_the_high_resolution_scale_keep_theirs(report_tool, tmp_path):
cache = Map(weapon_hud=[((1.0, 1.0), 0), ((0.75, 0.75), 3), ((1.0, 1.0), 2)])
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["hud_placements_rescaled"] == "0"
statics = u32_at(tags, cache.addresses["test\\weapon hud"] + 0x64)
assert (f32_at(tags, statics + 0xB4 + 0x28), u16_at(tags, statics + 0xB4 + 0x30)) == (0.75, 3)
@pytest.mark.parametrize("flag", [1 << 4, 1 << 7], ids=["half hud scale", "force hud use highres scale"])
def test_hud_elements_drawing_a_halo_pc_half_scale_bitmap_are_halved(report_tool, tmp_path, flag):
"""Halo PC's bitmap flags halve the scale of every HUD element drawing the
bitmap, as the element's own high resolution scale does: the first static
and the crosshair's item draw it, the second static does not."""
cache = Map(weapon_hud=[((1.0, 0.5), 1), ((1.0, 1.0), 1), ((2.0, 2.0), 0)], hud_bitmap_flags=flag)
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["hud_placements_rescaled"] == "2"
hud = cache.addresses["test\\weapon hud"]
statics = u32_at(tags, hud + 0x64)
items = u32_at(tags, u32_at(tags, hud + 0x88) + 0x38)
assert (f32_at(tags, statics + 0x28), f32_at(tags, statics + 0x2C), u16_at(tags, statics + 0x30)) == (0.5, 0.25, 1)
assert (f32_at(tags, statics + 0xB4 + 0x28), u16_at(tags, statics + 0xB4 + 0x30)) == (1.0, 1)
assert (f32_at(tags, items + 4), f32_at(tags, items + 8)) == (1.0, 1.0)
@pytest.mark.parametrize("name, tags_checksum, listed", [
(b"rev_snowcast_cavebeta", 1683840640, True),
(b"Rev_Snowcast_CaveBeta", 1683840640, True),
(b"rev_snowcast_cavebeta", 1683840641, False),
], ids=["listed", "name in another case", "another checksum"])
def test_maps_chimera_lists_get_halo_pc_behaviours(report_tool, tmp_path, name, tags_checksum, listed):
"""Chimera's map list knows a map by its name in lower case and its tag
data checksum. This one relies on, among others, bitmaps' half HUD scale
flags being ignored, so the static drawing a bitmap with one keeps its
scale."""
cache = Map(weapon_hud=[((1.0, 0.5), 1), ((1.0, 1.0), 1), ((2.0, 2.0), 0)], hud_bitmap_flags=1 << 4,
name=name, tags_checksum=tags_checksum)
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0
assert report["halo_pc_behaviours"] == (
"gearbox_chicago_multiply gearbox_meters hud_number_scale disable_bitmap_hud_scale_flags" if listed else "none")
assert report["hud_placements_rescaled"] == ("0" if listed else "2")
SCORE_HINT = 'Hold "%s" for score'
def test_the_score_hint_names_the_back_button(report_tool, tmp_path):
"""Halo PC formats the hint with its score key's name; this build copies
it as it is, so the placeholder becomes the Xbox button."""
cache = Map(strings_name="ui\\multiplayer_game_text", strings=[""] * 100 + [SCORE_HINT])
returncode, report, tags = converted(report_tool, cache, tmp_path)
assert returncode == 0 and report["score_hint_converted"] == "1"
assert "Hold BACK for score".encode("utf-16-le") in tags
assert SCORE_HINT.encode("utf-16-le") not in tags
def test_other_strings_keep_their_placeholders(report_tool, tmp_path):
for strings_name, strings in (("test\\strings", [""] * 100 + [SCORE_HINT]),
("ui\\multiplayer_game_text", [SCORE_HINT] * 100)):
cache = Map(strings_name=strings_name, strings=strings)
returncode, report, tags = converted(report_tool, cache, tmp_path / str(len(strings_name)))
assert returncode == 0 and report["score_hint_converted"] == "0"
assert SCORE_HINT.encode("utf-16-le") in tags
# ---------- the original (Xbox) format
def xbox_header(build=b"01.01.14.2342", version=5, file_length=0x4000, name=b"b30"):
header = bytearray(0x4000)
struct.pack_into("<IiI", header, 0, code("head"), version, file_length)
struct.pack_into("<II", header, 0x10, HEADER_BYTES, 0x100)
header[0x20:0x20 + len(name)] = name
header[0x40:0x40 + len(build)] = build
struct.pack_into("<I", header, 0x7FC, code("foot"))
return bytes(header)
@pytest.mark.parametrize("build", [b"01.01.14.2342", b"01.10.12.2276"])
def test_xbox_caches_are_recognized_and_left_to_the_original_loader(report_tool, tmp_path, build):
"""The module names the format and loads nothing: cache_file_header_verify
(source/cache/cache_files.c) alone judges Xbox caches, unchanged."""
path = tmp_path / "b30.map"
path.write_bytes(xbox_header(build))
returncode, report = report_one(report_tool, path)
assert returncode == 0
assert report["format"] == "Xbox cache"
assert report["identify"] == "ok"
assert report["build"] == build.decode()
assert "load" not in report and "milestone.load" not in report
def test_compressed_xbox_caches_are_recognized(report_tool, tmp_path):
"""Retail Xbox maps declare their decompressed length, beyond the file."""
path = tmp_path / "a10.map"
path.write_bytes(xbox_header(file_length=0x10000000))
_, report = report_one(report_tool, path)
assert report["format"] == "Xbox cache" and report["identify"] == "ok"
def test_other_cache_versions_are_named_not_loaded(report_tool, tmp_path):
path = tmp_path / "pc.map"
path.write_bytes(xbox_header(build=b"01.00.00.0564", version=7))
returncode, report = report_one(report_tool, path)
assert returncode == 1
assert report["format"] == "cache of an unknown version"
assert report["identify"] == "the cache version is not one this build knows"
def test_unrelated_files_are_not_recognized(report_tool, tmp_path):
path = tmp_path / "notes.map"
path.write_bytes(b"not a map at all, just some text" * 4)
returncode, report = report_one(report_tool, path)
assert returncode == 1
assert report["format"] == "not a Halo map file"
# ---------- malformed input: every check, one at a time
def header_case(offset, fmt, value):
return lambda cache, path: patched(path, offset, fmt, value)
def tag_data_case(where, delta, fmt, value):
return lambda cache, path: patched(path, cache.where[where] + delta, fmt, value)
def instance_case(tag_index, delta, fmt, value):
return lambda cache, path: patched(path, cache.where["instances"] + tag_index * 0x20 + delta, fmt, value)
MALFORMED_CACHES = {
"bad footer": (header_case(0x7FC, "<I", 0), "identify", "the header or footer signature is wrong"),
"unterminated name": (header_case(0x20, "32s", b"N" * 32), "identify",
"a name or build string in the header is not terminated"),
"unterminated build": (header_case(0x40, "32s", b"9" * 32), "identify",
"a name or build string in the header is not terminated"),
"file length beyond file": (header_case(0x08, "<I", 0x7FFFFFFF), "identify",
"the file length in the header does not fit the file or the size limit"),
"file length inside header": (header_case(0x08, "<I", 0x10), "identify",
"the file length in the header does not fit the file or the size limit"),
"compressed": (header_case(0x0C, "<I", 0x1000), "identify",
"the cache is compressed, which Custom Edition caches never are"),
"tag data in header": (header_case(0x10, "<I", 0x100), "identify",
"the tag data range in the header does not fit the file or the tag cache"),
"tag data beyond file": (header_case(0x14, "<I", 0x7FFFFFF0), "identify",
"the tag data range in the header does not fit the file or the tag cache"),
"tag data too small": (header_case(0x14, "<I", 0x10), "identify",
"the tag data range in the header does not fit the file or the tag cache"),
"tag index signature": (tag_data_case("index", 0x24, "<I", code("sgat")), "load",
"the tag index signature is not 'tags'"),
"no tags": (tag_data_case("index", 0x0C, "<i", 0), "load", "the tag instances do not fit in the tag data"),
"negative tag count": (tag_data_case("index", 0x0C, "<i", -5), "load",
"the tag instances do not fit in the tag data"),
"too many tags": (tag_data_case("index", 0x0C, "<i", 0x7FFFFFF), "load",
"the tag instances do not fit in the tag data"),
"instances outside": (tag_data_case("index", 0x00, "<I", 0x10000000), "load",
"the tag instances do not fit in the tag data"),
"instances over index": (tag_data_case("index", 0x00, "<I", BASE + 4), "load",
"the tag instances do not fit in the tag data"),
"instances unaligned": (tag_data_case("index", 0x00, "<I", BASE + 0x29), "load",
"the tag instances do not fit in the tag data"),
"model data beyond file": (tag_data_case("index", 0x20, "<I", 0x7FFFFFFF), "load",
"the model vertex and index data do not fit in the file"),
"index data beyond model data": (tag_data_case("index", 0x1C, "<I", 0x10000), "load",
"the model vertex and index data do not fit in the file"),
"model data in header": (tag_data_case("index", 0x14, "<I", 0x10), "load",
"the model vertex and index data do not fit in the file"),
"handle": (instance_case(3, 0x0C, "<I", 0xE1770009), "load",
"a tag handle does not match its position in the index"),
"name outside": (instance_case(2, 0x10, "<I", 0x3FFFFFFF), "load",
"a tag name lies outside the tag data or is not terminated"),
"tag address outside": (instance_case(8, 0x14, "<I", BASE + 0x1000000), "load",
"a tag's address lies outside the tag data"),
"null tag address": (instance_case(8, 0x14, "<I", 0), "load", "a tag's address lies outside the tag data"),
"external weapon": (instance_case(8, 0x18, "<I", 1), "load",
"a tag of a group that resource maps never hold is marked as held by one"),
"external sound header outside": (instance_case(4, 0x14, "<I", BASE - 0x100), "load",
"a tag's address lies outside the tag data"),
"scenario index": (tag_data_case("index", 0x04, "<I", 0xE17400FF), "load",
"the scenario tag is missing, misplaced or not a scenario"),
"scenario salt": (tag_data_case("index", 0x04, "<I", 0x12340000), "load",
"the scenario tag is missing, misplaced or not a scenario"),
"external scenario": (instance_case(0, 0x18, "<I", 1), "load",
"a tag of a group that resource maps never hold is marked as held by one"),
"bsp count": (tag_data_case("scenario", 0x5A4, "<i", 33), "load",
"the scenario's structure BSP block is not valid"),
"bsp block outside": (tag_data_case("scenario", 0x5A8, "<I", 0x10), "load",
"the scenario's structure BSP block is not valid"),
"bsp reference to a weapon": (tag_data_case("reference", 0x1C, "<I", (0xE174 + 8) << 16 | 8), "load",
"the scenario's structure BSP block is not valid"),
"bsp beyond file": (tag_data_case("reference", 0x04, "<i", 0x7FFFFF00), "load",
"a structure BSP does not fit in the file or in the tag cache"),
"bsp in header": (tag_data_case("reference", 0x00, "<i", 0x10), "load",
"a structure BSP does not fit in the file or in the tag cache"),
"bsp over tag data": (tag_data_case("reference", 0x08, "<I", BASE + 0x100), "load",
"a structure BSP does not fit in the file or in the tag cache"),
"bsp beyond tag cache": (tag_data_case("reference", 0x08, "<I", BASE + TAG_CACHE_BYTES - 0x800), "load",
"a structure BSP does not fit in the file or in the tag cache"),
"bsp signature": (lambda cache, path: patched(path, cache.where["bsp"] + 0x14, "<I", code("psbs")), "load",
"a structure BSP header is not valid"),
"bsp xbox vertex buffers": (lambda cache, path: patched(path, cache.where["bsp"] + 0x04, "<i", 12), "load",
"a structure BSP header is not valid"),
"bsp pointer": (lambda cache, path: patched(path, cache.where["bsp"], "<I", BASE), "load",
"a structure BSP header is not valid"),
"bitmap resource index": (instance_case(3, 0x14, "<I", 7), "load",
"a tag's entry is missing from its resource map"),
"bitmap resource name": (instance_case(3, 0x14, "<I", 0), "load",
"a tag's entry is missing from its resource map"),
"string resource index": (instance_case(5, 0x14, "<I", 0xFFFFFFFF), "load",
"a tag's entry is missing from its resource map"),
"in-map pixels beyond file": (tag_data_case("in_map_bitmap", 0x1C, "<I", 0x7FFFFFFF), "load",
"bitmap pixels or sound samples lie outside their file"),
"in-map bitmaps block outside": (tag_data_case("in_map_bitmap", -0x6C + 0x64, "<I", 0x10), "load",
"a tag block or tag data field lies outside the loaded tags"),
}
@pytest.mark.parametrize("case", sorted(MALFORMED_CACHES))
def test_malformed_caches_are_rejected_with_the_specific_reason(report_tool, tmp_path, case):
corrupt, stage, message = MALFORMED_CACHES[case]
cache = Map()
path = cache.write(tmp_path)
corrupt(cache, path)
returncode, report = report_one(report_tool, path)
assert returncode == 1
if stage == "identify":
assert report["identify"] == message
assert "milestone.load" not in report
else:
assert report["identify"] == "ok"
assert report["load"] == message
assert report["milestone.load"] == "no"
def test_a_file_length_of_zero_is_the_whole_file(report_tool, tmp_path):
"""Invader leaves the header's file length 0 (blood_covenantv3)."""
path = Map().write(tmp_path, "test.map")
returncode, report = report_one(report_tool, patched(path, 0x08, "<I", 0))
assert returncode == 0 and report["identify"] == "ok" and report["load"] == "ok"
assert report["file_length"] == hex(path.stat().st_size)
def test_too_small_files_are_rejected(report_tool, tmp_path):
tiny = tmp_path / "tiny.map"
tiny.write_bytes(b"12345678")
_, report = report_one(report_tool, tiny)
assert report["identify"] == "the file is too small to hold a header"
headless = tmp_path / "headless.map"
headless.write_bytes(struct.pack("<I", code("head")) + bytes(0x100))
_, report = report_one(report_tool, headless)
assert report["identify"] == "the file is too small to hold a header"
def test_missing_and_wrong_resource_maps_are_named(report_tool, tmp_path):
path = Map().write(tmp_path)
(tmp_path / "sounds.map").unlink()
returncode, report = report_one(report_tool, path)
assert returncode == 1
assert report["resource_map.sounds"].endswith("(not found)")
assert report["load"] == "a map needs a resource map that was not supplied"
(tmp_path / "sounds.map").write_bytes((tmp_path / "loc.map").read_bytes())
_, report = report_one(report_tool, path)
assert report["resource_map.sounds"].endswith("(a resource map is not of the type needed)")
assert report["load"] == "a map needs a resource map that was not supplied"
def resource_map_case(name, mutate):
def corrupt(folder):
path = folder / f"{name}.map"
data = bytearray(path.read_bytes())
mutate(data)
path.write_bytes(data)
return corrupt
def index_entry(data, item_index):
return struct.unpack_from("<iiii", data, 0)[2] + item_index * 12
MALFORMED_RESOURCE_MAPS = {
"index beyond file": (resource_map_case("bitmaps", lambda d: struct.pack_into("<i", d, 8, len(d))),
"resource_map.bitmaps", "a resource map header is not valid"),
"names after index": (resource_map_case("bitmaps", lambda d: struct.pack_into("<i", d, 4, len(d) - 4)),
"resource_map.bitmaps", "a resource map header is not valid"),
"count beyond file": (resource_map_case("loc", lambda d: struct.pack_into("<i", d, 12, 0x10000000)),
"resource_map.loc", "a resource map header is not valid"),
"item data beyond names": (resource_map_case("sounds", lambda d: struct.pack_into("<i", d, index_entry(d, 1) + 4, 0x100000)),
"resource_map.sounds", "a resource map entry lies outside the file or its name is not terminated"),
"item name beyond names": (resource_map_case("loc", lambda d: struct.pack_into("<i", d, index_entry(d, 0), 0x100000)),
"resource_map.loc", "a resource map entry lies outside the file or its name is not terminated"),
"item data in header": (resource_map_case("loc", lambda d: struct.pack_into("<i", d, index_entry(d, 2) + 8, 4)),
"resource_map.loc", "a resource map entry lies outside the file or its name is not terminated"),
}
@pytest.mark.parametrize("case", sorted(MALFORMED_RESOURCE_MAPS))
def test_malformed_resource_maps_are_rejected(report_tool, tmp_path, case):
corrupt, key, message = MALFORMED_RESOURCE_MAPS[case]
path = Map().write(tmp_path)
corrupt(tmp_path)
returncode, report = report_one(report_tool, path)
assert returncode == 1
assert report[key].endswith(f"({message})")
assert report["load"] == "a map needs a resource map that was not supplied"
def test_resource_tags_outside_their_entries_are_rejected(report_tool, tmp_path):
def corrupt_first_block(data):
# the bitmap definition is entry 1; its sequence block's address
entry = index_entry(data, 1)
item_offset = struct.unpack_from("<i", data, entry + 8)[0]
struct.pack_into("<I", data, item_offset + 0x54 + 4, 0x10000)
path = Map().write(tmp_path)
resource_map_case("bitmaps", corrupt_first_block)(tmp_path)
_, report = report_one(report_tool, path)
assert report["load"] == "a tag held by a resource map does not have the documented layout"
def test_font_style_references_must_be_empty(report_tool, tmp_path):
_, report = report_one(report_tool, Map(font_style_reference=0xE1740003).write(tmp_path))
assert report["load"] == "a tag held by a resource map does not have the documented layout"
def test_resource_pixels_and_samples_beyond_their_files_are_rejected(report_tool, tmp_path):
_, report = report_one(report_tool, Map(bitmap_pixels_size=0x7FFFFFFF).write(tmp_path / "pixels"))
assert report["load"] == "bitmap pixels or sound samples lie outside their file"
_, report = report_one(report_tool, Map(sound_samples_size=0x100000).write(tmp_path / "samples"))
assert report["load"] == "bitmap pixels or sound samples lie outside their file"
def test_resource_tags_must_fit_below_the_structure_bsp(report_tool, tmp_path):
cache = Map()
cache.build()
tag_data_bytes = len(cache.build()) - cache.where["tag_data"]
# the BSP starts right where the tag data ends: no room for resource tags
_, report = report_one(report_tool, Map(bsp_gap=tag_data_bytes).write(tmp_path))
assert report["load"] == "the tag data and the tags held by resource maps do not fit below the structure BSP"
def test_missing_sound_entry_is_named(report_tool, tmp_path):
path = Map().write(tmp_path)
data = bytearray((tmp_path / "sounds.map").read_bytes())
names_offset = struct.unpack_from("<i", data, 4)[0]
at = data.index(b"test\\sound\0", names_offset + 1)
data[at:at + 10] = b"test\\sounx"
(tmp_path / "sounds.map").write_bytes(data)
_, report = report_one(report_tool, path)
assert report["load"] == "a tag's entry is missing from its resource map"
def test_seeded_corruption_never_crashes_the_loader(report_tool, tmp_path):
"""Sampled robustness, not proof: flip bytes in the header, tag index,
tag data and resource maps of a valid cache and require a clean verdict
(exit 0 or 1, checked by run_report) every time."""
cache = Map()
pristine = cache.build()
resource_maps = cache.resource_maps()
generator = random.Random(20260926)
for trial in range(120):
folder = tmp_path / f"trial{trial}"
folder.mkdir()
data = bytearray(pristine)
targets = {"map": data}
for type_name, contents in resource_maps.items():
targets[type_name] = bytearray(contents)
for _ in range(generator.randint(1, 8)):
name = generator.choice(sorted(targets))
target = targets[name]
if name == "map" and generator.random() < 0.7:
offset = generator.randrange(cache.where["tag_data"], len(target))
else:
offset = generator.randrange(len(target))
target[offset] = generator.randrange(256)
(folder / "test.map").write_bytes(targets["map"])
for type_name in resource_maps:
(folder / f"{type_name}.map").write_bytes(targets[type_name])
run_report(report_tool, folder / "test.map")
# ---------- real maps, when present (never committed)
def real_maps_directory():
configured = os.environ.get("HALO_CUSTOM_EDITION_MAPS")
folder = Path(configured) if configured else ROOT / "assets" / "custom_edition"
return folder if (folder / "bitmaps.map").is_file() else None
STOCK_CUSTOM_EDITION_MAPS = [
"beavercreek", "bloodgulch", "boardingaction", "carousel", "chillout", "damnation", "dangercanyon",
"deathisland", "gephyrophobia", "hangemhigh", "icefields", "infinity", "longest", "prisoner",
"putput", "ratrace", "sidewinder", "timberland", "ui", "wizard",
]
@pytest.fixture(scope="module")
def real_maps():
folder = real_maps_directory()
if folder is None:
pytest.skip("no Custom Edition maps: set HALO_CUSTOM_EDITION_MAPS or extract them to assets/custom_edition")
return folder
def test_real_maps_every_stock_map_loads_with_a_matching_checksum(report_tool, real_maps):
present = [real_maps / f"{name}.map" for name in STOCK_CUSTOM_EDITION_MAPS if (real_maps / f"{name}.map").is_file()]
if not present:
pytest.skip("no stock Custom Edition maps present")
returncode, blocks = run_report(report_tool, *present)
assert returncode == 0
for block in blocks:
assert block["load"] == "ok", block["file"]
assert block["computed_checksum"] == block["checksum"], block["file"]
assert block["warnings"] == "none", block["file"]
def test_real_maps_resource_maps_are_valid(report_tool, real_maps):
returncode, blocks = run_report(report_tool, real_maps / "bitmaps.map", real_maps / "sounds.map",
real_maps / "loc.map")
assert returncode == 0
assert [b["resource_map"] for b in blocks] == ["ok", "ok", "ok"]
def test_real_maps_opensauce_caches_are_recognized(report_tool, real_maps):
"""Each .yelo map present is either refused for needing OpenSauce or
identified as a Custom Edition cache (a .yelo that asks for nothing of
OpenSauce's; the game never looks for .yelo files)."""
paths = sorted(real_maps.glob("*.yelo"))
if not paths:
pytest.skip("no OpenSauce caches present")
_, blocks = run_report(report_tool, *paths)
for block in blocks:
assert block["identify"] in (OPENSAUCE_REFUSED, "ok"), block["file"]
def test_real_maps_convert_as_recorded(report_tool, real_maps):
"""The map run in the native build (docs/custom_edition_caches.md)
converts as recorded there."""
recorded = {
"bloodgulch.map": {"structure_bsp_materials_checked": "79", "shaders_renumbered": "21",
"chicago_extended_shaders_converted": "10", "bitmaps_prepared": "676",
"animation_overlays_disabled": "0",
"sounds_undecodable": "39", "hud_placements_rescaled": "32",
"score_hint_converted": "1"},
}
present = [real_maps / name for name in recorded if (real_maps / name).is_file()]
if not present:
pytest.skip("the recorded map is not present")
returncode, blocks = run_report(report_tool, *present)
assert returncode == 0
for block in blocks:
name = Path(block["file"]).name
assert block["convert"] == "ok", name
assert {key: block[key] for key in recorded[name]} == recorded[name], name