TR2X/tools/render_progress
2023-10-01 13:24:47 +02:00

748 lines
20 KiB
Python
Executable File

#!/usr/bin/python3
import re
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from decimal import Decimal
from enum import StrEnum, auto
from functools import cache
from itertools import groupby
from pathlib import Path
from typing import Any
DOCUMENT_MARGIN = Decimal(2)
GRID_MAX_SQUARES = 50
GRID_SQUARE_SIZE = Decimal(12)
GRID_SQUARE_MARGIN = Decimal(3)
PROGRESS_BAR_SIZE = Decimal(6)
LEGEND_SQUARE_SIZE = GRID_SQUARE_SIZE
LEGEND_SQUARE_MARGIN = GRID_SQUARE_MARGIN
LEGEND_ROW_PADDING = Decimal(3)
LEGEND_MARGIN = Decimal(15)
TEXT_SIZE = Decimal(15)
TEXT_MARGIN = Decimal(5)
SECTION_MARGIN = GRID_SQUARE_SIZE + LEGEND_MARGIN
DOCS_DIR = Path(__file__).parent.parent / "docs"
PROGRESS_TXT_FILE = DOCS_DIR / "progress.txt"
PROGRESS_SVG_FILE = DOCS_DIR / "progress.svg"
GRID_WIDTH = (
GRID_MAX_SQUARES * (GRID_SQUARE_SIZE + GRID_SQUARE_MARGIN)
- GRID_SQUARE_MARGIN
)
ZERO = Decimal(0)
EPSILON = Decimal("0.1")
class SumMode(StrEnum):
BYTES = auto()
COUNT = auto()
class FunctionStatus(StrEnum):
DECOMPILED = auto()
KNOWN = auto()
TODO = auto()
UNUSED = auto()
@dataclass
class MyFraction:
"""Fraction that doesn't normalize (eg 8/12 doesn't become 2/3)"""
numerator: int
denominator: int
def format_decimal(source: Decimal) -> str:
return str(source.quantize(Decimal("1.11"))).replace(".00", "")
def format_percent(source: MyFraction) -> str:
return format_decimal(fraction_to_decimal(source) * 100) + "%"
def fraction_to_decimal(source: MyFraction) -> Decimal:
return Decimal(source.numerator) / Decimal(source.denominator)
@dataclass
class Box:
x1: Decimal
y1: Decimal
x2: Decimal
y2: Decimal
@property
def dx(self) -> Decimal:
return self.x2 - self.x1
@property
def dy(self) -> Decimal:
return self.y2 - self.y1
@dataclass
class SquarifyResult(Box):
item: Any
class Squarify:
def __init__(
self,
items: Iterable[Any],
key: Callable[[Any], Decimal],
box: Box,
normalize: bool = True,
) -> None:
self.key: Callable[[Any], Decimal] = key
if normalize:
total_size = sum(map(key, items))
total_area = box.dx * box.dy
def normalized_key(item: Any) -> Decimal:
return key(item) * total_area / total_size
self.key = normalized_key
self.items = list(sorted(items, key=self.key, reverse=True))
self.box = box
def layoutrow(self, items: Iterable[Any]) -> Iterable[SquarifyResult]:
covered_area = sum(self.key(item) for item in items)
dx = covered_area / self.box.dy
y = self.box.y1
for item in items:
yield SquarifyResult(
item=item,
x1=self.box.x1,
y1=y,
x2=self.box.x1 + dx,
y2=y + self.key(item) / dx,
)
y += self.key(item) / dx
def layoutcol(self, items: Iterable[Any]) -> Iterable[SquarifyResult]:
covered_area = sum(self.key(item) for item in items)
dy = covered_area / self.box.dx
x = self.box.x1
for item in items:
yield SquarifyResult(
item=item,
x1=x,
y1=self.box.y1,
x2=x + self.key(item) / dy,
y2=self.box.y1 + dy,
)
x += self.key(item) / dy
def layout(self, items: Iterable[Any]) -> Iterable[SquarifyResult]:
yield from (
self.layoutrow(items)
if self.box.dx >= self.box.dy
else self.layoutcol(items)
)
def leftoverrow(self, items: Iterable[Any]) -> Box:
covered_area = sum(self.key(item) for item in items)
dx = covered_area / self.box.dy
return Box(
x1=self.box.x1 + dx,
y1=self.box.y1,
x2=self.box.x1 + self.box.dx,
y2=self.box.y1 + self.box.dy,
)
def leftovercol(self, items: Iterable[Any]) -> Box:
covered_area = sum(self.key(item) for item in items)
dy = covered_area / self.box.dx
return Box(
x1=self.box.x1,
y1=self.box.y1 + dy,
x2=self.box.x1 + self.box.dx,
y2=self.box.y1 + self.box.dy,
)
def leftover(self, items: Iterable[Any]) -> Box:
return (
self.leftoverrow(items)
if self.box.dx >= self.box.dy
else self.leftovercol(items)
)
def worst_ratio(self, items: Iterable[Any]) -> Decimal:
return max(
max(result.dx / result.dy, result.dy / result.dx)
for result in self.layout(items)
)
def run(self, items: list[Any] | None = None) -> Iterable[SquarifyResult]:
if not items:
items = self.items
if len(items) == 0:
return
if len(items) == 1:
yield from self.layout(items)
return
i = 1
while i < len(items) and self.worst_ratio(
items[:i]
) >= self.worst_ratio(items[: i + 1]):
i += 1
current = items[:i]
remaining = items[i:]
leftover_box = self.leftover(current)
yield from self.layout(current)
yield from Squarify(
remaining, key=self.key, box=leftover_box, normalize=False
).run()
def squarify(
items: list[Any], key: Callable[[Any], Decimal], box: Box
) -> Iterable[SquarifyResult]:
yield from Squarify(items, key, box).run()
@dataclass
class Function:
signature: str
offset: int
size: int
flags: str
@property
def is_decompiled(self) -> bool:
return "+" in self.flags
@property
def is_called(self) -> bool:
return "*" in self.flags
@property
def is_unused(self) -> bool:
return "x" in self.flags
@property
def is_known(self) -> bool:
return not re.search(r"(\s|^)sub_", self.signature)
@property
def status(self) -> FunctionStatus:
if self.is_decompiled:
return FunctionStatus.DECOMPILED
elif self.is_unused:
return FunctionStatus.UNUSED
elif self.is_known:
return FunctionStatus.KNOWN
return FunctionStatus.TODO
def collect_functions() -> Iterable[Function]:
in_functions = False
for line in PROGRESS_TXT_FILE.open():
line = line.strip()
if line == "# FUNCTIONS":
in_functions = True
elif re.match("^# [A-Z]*$", line):
in_functions = False
if not in_functions:
continue
if line.startswith("#") or not line:
continue
offset, size, flags, func_signature = re.split(
r"\s+", line, maxsplit=3
)
if not offset.replace("-", ""):
continue
yield Function(
signature=func_signature,
offset=int(offset, 16),
size=int(size, 16),
flags=flags,
)
class Shape:
box: Box = ...
def render(self) -> str:
raise NotImplementedError("not implemented")
class Container(Shape):
def __init__(self):
self.children: list[Shape] = []
def __post_init__(self) -> None:
self.box = get_common_bbox(self.children)
def render(self) -> str:
return "\n".join(child.render() for child in self.children)
def get_common_bbox(shapes: list[Shape]) -> Box:
return Box(
x1=min(shape.box.x1 for shape in shapes),
y1=min(shape.box.y1 for shape in shapes),
x2=max(shape.box.x2 for shape in shapes),
y2=max(shape.box.y2 for shape in shapes),
)
@dataclass
class Rectangle(Shape):
x: Decimal
y: Decimal
class_name: str
dx: Decimal
dy: Decimal
title: str | None = None
def __post_init__(self) -> None:
self.box = Box(
x1=self.x,
y1=self.y,
x2=self.x + self.dx,
y2=self.y + self.dy,
)
def render(self) -> str:
return (
f"<rect "
f'width="{format_decimal(max(self.dx, EPSILON))}" '
f'height="{format_decimal(max(self.dy, EPSILON))}" '
f'x="{format_decimal(self.x)}" '
f'y="{format_decimal(self.y)}" '
f'class="{self.class_name}"'
+ (f"><title>{self.title}</title></rect>" if self.title else "/>")
)
class Square(Rectangle):
def __init__(
self,
x: Decimal,
y: Decimal,
class_name: str,
size: Decimal = GRID_SQUARE_SIZE,
title: str | None = None,
) -> None:
super().__init__(
x=x, y=y, class_name=class_name, dx=size, dy=size, title=title
)
@dataclass
class Text(Shape):
x: Decimal
y: Decimal
text: str
class_name: str | None = None
size: Decimal = TEXT_SIZE
def __post_init__(self) -> None:
self.style = ""
if self.size != TEXT_SIZE:
self.style = f"font-size: {self.size}px; "
self.box = Box(x1=self.x, y1=self.y, x2=self.x, y2=self.y + self.size)
def render(self) -> str:
return (
f"<text "
+ (f'class="{self.class_name}" ' if self.class_name else "")
+ (f'style="{self.style}" ' if self.style else "")
+ f'x="{format_decimal(self.x)}" '
f'y="{format_decimal(self.y + self.size/2)}">'
f"{self.text}"
f"</text>"
)
class LegendText(Container):
def __init__(self, class_name: str, text: str) -> None:
super().__init__()
text_shape = Text(
x=LEGEND_SQUARE_SIZE + TEXT_MARGIN,
y=ZERO,
text=text,
)
square_shape = Square(
x=ZERO,
y=(text_shape.size - LEGEND_SQUARE_SIZE) / 2,
class_name=class_name,
size=LEGEND_SQUARE_SIZE,
)
self.children.extend([text_shape, square_shape])
self.__post_init__()
@dataclass
class TranslateShape(Container):
def __init__(
self, children: list[Shape], x: Decimal = ZERO, y: Decimal = ZERO
) -> None:
self.children = children
self.x = x
self.y = y
super().__post_init__()
self.box.x1 += self.x
self.box.x2 += self.x
self.box.y1 += self.y
self.box.y2 += self.y
def render(self) -> str:
return (
f'<g transform="translate({format_decimal(self.x)} {format_decimal(self.y)})">\n'
f"{super().render()}\n"
"</g>"
)
@dataclass
class LegendSection(Container):
def __init__(self, all_functions: list[Function]) -> None:
super().__init__()
functions_status_map = get_functions_status_map(all_functions)
self.title = Text(x=ZERO, y=0, text="Legend:")
self.children.append(self.title)
captions = {
FunctionStatus.DECOMPILED: "Function fully decompiled",
FunctionStatus.UNUSED: "Function not used by the game",
FunctionStatus.KNOWN: "Function not yet decompiled, but with a known signature",
FunctionStatus.TODO: "Function not yet decompiled, with an unknown signature",
}
y = self.title.size + TEXT_MARGIN
for status in FunctionStatus:
caption = captions[status]
functions = functions_status_map[status]
legend_text = TranslateShape(
[LegendText(class_name=status.value, text=caption)], y=y
)
self.children.append(legend_text)
y = legend_text.box.y2 + LEGEND_ROW_PADDING
self.__post_init__()
class FunctionGrid(Container):
def __init__(self, all_functions: list[Function]) -> None:
super().__init__()
for i, function in enumerate(all_functions):
x = (i % GRID_MAX_SQUARES) * (
GRID_SQUARE_SIZE + GRID_SQUARE_MARGIN
)
y = (i // GRID_MAX_SQUARES) * (
GRID_SQUARE_SIZE + GRID_SQUARE_MARGIN
)
self.children.append(
Square(
x=x,
y=y,
class_name=function.status.value,
title=function.signature,
)
)
self.__post_init__()
class FunctionTreeGrid(Container):
def __init__(self, all_functions: list[Function]) -> None:
super().__init__()
box = Box(
x1=ZERO,
y1=ZERO,
x2=GRID_WIDTH + GRID_SQUARE_MARGIN,
y2=(
(
(len(all_functions) + GRID_MAX_SQUARES - 1)
// GRID_MAX_SQUARES
)
* (GRID_SQUARE_SIZE + GRID_SQUARE_MARGIN)
) + GRID_SQUARE_MARGIN,
)
for result in squarify(
all_functions, key=lambda function: Decimal(function.size), box=box
):
result.x2 = max(ZERO, result.x2 - GRID_SQUARE_MARGIN)
result.y2 = max(ZERO, result.y2 - GRID_SQUARE_MARGIN)
self.children.append(
Rectangle(
x=result.x1,
y=result.y1,
dx=result.dx,
dy=result.dy,
class_name=result.item.status.value,
title=result.item.signature,
)
)
self.__post_init__()
def get_function_done_percentage(
chosen_functions: list[Function],
all_functions: list[Function],
mode: SumMode,
) -> MyFraction:
match mode:
case SumMode.COUNT:
return MyFraction(len(chosen_functions), len(all_functions))
case SumMode.BYTES:
return MyFraction(
sum(function.size for function in chosen_functions),
sum(function.size for function in all_functions),
)
case _:
assert False
class ProgressBar(Container):
def __init__(self, all_functions: list[Function], mode: SumMode) -> None:
super().__init__()
lx2 = ZERO
def sorter(function):
return list(FunctionStatus).index(function.status)
for status_value, group in groupby(
sorted(all_functions, key=sorter), sorter
):
chosen_functions = list(group)
if not chosen_functions:
continue
status = chosen_functions[0].status
fraction = get_function_done_percentage(
chosen_functions, all_functions, mode=mode
)
ratio = fraction_to_decimal(fraction)
lx1 = lx2
lx2 += ratio * GRID_WIDTH
self.children.append(
Rectangle(
x=lx1,
y=ZERO,
dx=lx2 - lx1,
dy=PROGRESS_BAR_SIZE,
class_name=status,
)
)
self.__post_init__()
def get_functions_status_map(
all_functions: list[Function],
) -> dict[FunctionStatus, list[Function]]:
functions_status_map: dict[FunctionStatus, list[Function]] = {
status: [] for status in FunctionStatus
}
for function in all_functions:
functions_status_map[function.status].append(function)
return functions_status_map
def get_progress_parts(
all_functions: list[Function],
) -> dict[FunctionStatus, tuple[list[Function], dict[SumMode, MyFraction]]]:
functions_status_map = get_functions_status_map(all_functions)
return {
status: (
functions_status_map[status],
{
mode: get_function_done_percentage(
functions_status_map[status], all_functions, mode=mode
)
for mode in SumMode
},
)
for status in FunctionStatus
}
class GridSection(Container):
def __init__(
self, header: str, all_functions: list[Function], mode: SumMode
) -> None:
super().__init__()
header_shape = Text(x=ZERO, y=ZERO, text=header)
y = header_shape.size + TEXT_MARGIN
x2 = GRID_WIDTH
progress_bar = TranslateShape(
[ProgressBar(all_functions, mode=mode)], y=y
)
y = progress_bar.box.y2 + TEXT_MARGIN
if mode == SumMode.COUNT:
grid = TranslateShape([FunctionGrid(all_functions)], y=y)
else:
grid = TranslateShape([FunctionTreeGrid(all_functions)], y=y)
progress_parts = get_progress_parts(all_functions)
progress_text = Text(
x=GRID_WIDTH,
y=Decimal(3),
size=Decimal(12),
class_name=FunctionStatus.TODO.value,
text=(
'<tspan text-anchor="end">'
+ " · ".join(
f'<tspan class="{status.value}">'
+ f"{format_percent(progress_parts[status][1][mode])}"
+ (
f" ({progress_parts[status][1][mode].numerator})"
if mode == SumMode.COUNT
else ""
)
+ "</tspan>"
for status in FunctionStatus
)
+ "</tspan>"
),
)
self.children.extend(
[
header_shape,
progress_text,
progress_bar,
grid,
]
)
self.__post_init__()
class ProgressSVG(Container):
def __init__(self, all_functions: list[Function]) -> None:
super().__init__()
y = ZERO
section1 = GridSection(
header="Tomb2.exe progress according to the physical function order:",
all_functions=all_functions,
mode=SumMode.COUNT,
)
section2 = GridSection(
header="Tomb2.exe progress according to the function sizes:",
all_functions=all_functions,
mode=SumMode.BYTES,
)
legend = LegendSection(all_functions)
section1_wrap = TranslateShape(
[section1], y=legend.box.y2 + SECTION_MARGIN
)
section2_wrap = TranslateShape(
[section2], y=section1_wrap.box.y2 + SECTION_MARGIN
)
legend_wrap = TranslateShape([legend])
self.children.extend(
[
legend_wrap,
section1_wrap,
section2_wrap,
]
)
super().__post_init__()
def render(self) -> str:
x1 = -DOCUMENT_MARGIN
y1 = -DOCUMENT_MARGIN
x2 = self.box.dx + DOCUMENT_MARGIN * 2
y2 = self.box.dy + DOCUMENT_MARGIN * 2
return (
f"""<svg version="1.1"
viewBox="{format_decimal(x1)} {format_decimal(y1)} {format_decimal(x2)} {format_decimal(y2)}"
xmlns="http://www.w3.org/2000/svg">
<style>
.todo {{
--bg: rgba(255, 255, 255, 0.25);
--fg: rgba(100, 100, 100, 0.5);
}}
.known {{
--bg: rgba(255, 255, 0, 0.25);
--fg: rgba(250, 150, 0, 0.85);
}}
.decompiled {{
--bg: rgba(0, 255, 0, 0.5);
--fg: rgba(0, 150, 0, 0.85);
}}
.unused {{
--bg: rgba(255, 0, 0, 0.05);
--fg: rgba(250, 0, 0, 0.5);
}}
rect {{
fill: var(--bg);
stroke: var(--fg);
stroke-width: 0.5;
}}
rect.unused {{
fill: url("#unused-cross");
}}
text {{
alignment-baseline: central;
font-family: sans-serif;
font-size: {TEXT_SIZE}px;
fill: var(--fg, black);
stroke: none;
}}
@media (prefers-color-scheme: dark) {{
text {{
fill: var(--fg, white);
}}
}}
tspan {{
alignment-baseline: central;
fill: var(--fg, inherit);
}}
</style>
<defs>
<pattern id='unused-cross' x='0' y='0' width='100%' height='100%' viewBox='0 0 12 12' preserveAspectRatio='none'>
<path d='M0,0L12,12M0,12L12,0' stroke-width='0.5' stroke='red'/>
</pattern>
</defs>
"""
+ super().render()
+ "\n</svg>"
)
def main() -> None:
functions = list(collect_functions())
with PROGRESS_SVG_FILE.open("w") as handle:
svg = ProgressSVG(functions)
print(svg.render(), file=handle)
if __name__ == "__main__":
main()