Skip to content

Commit c9e2bc5

Browse files
authored
Pack: trace the game's own icon when SPEED2.EXE is present (#21)
The app icon is the game's own icon, traced to vectors from your SPEED2.EXE when packing (falls back to the drawn icon without it). No icon file is added to the repository.
1 parent 6903fa5 commit c9e2bc5

3 files changed

Lines changed: 290 additions & 4 deletions

File tree

‎pack.sh‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,10 +98,15 @@ chmod +x "$APP/Contents/MacOS/"*
9898
printf 'APPL????' > "$APP/Contents/PkgInfo"
9999

100100
# --- icon ---
101-
if [ -x "$ROOT/.venv/bin/python" ]; then
102-
"$ROOT/.venv/bin/python" "$ROOT/tools/mkicon.py" "$APP/Contents/Resources/AppIcon.icns" >/dev/null
103-
else
101+
# The game's own icon, traced to vectors, when SPEED2.EXE is here (tools/exeicon.py;
102+
# ICON_EXE= picks another one); otherwise the drawn speedometer (tools/mkicon.py).
103+
ICON_EXE="${ICON_EXE:-$ROOT/Need for Speed Underground 2 - Clean Edition/Need for Speed Underground 2/SPEED2.EXE}"
104+
if [ ! -x "$ROOT/.venv/bin/python" ]; then
104105
echo "warning: no .venv, icon not generated"
106+
elif [ -f "$ICON_EXE" ] && "$ROOT/.venv/bin/python" "$ROOT/tools/exeicon.py" "$ICON_EXE" "$APP/Contents/Resources/AppIcon.icns" >/dev/null; then
107+
:
108+
else
109+
"$ROOT/.venv/bin/python" "$ROOT/tools/mkicon.py" "$APP/Contents/Resources/AppIcon.icns" >/dev/null
105110
fi
106111

107112
# --- embedded game data (--embed only) ---

‎requirements.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44
capstone>=5.0.6,<6
55
pefile>=2023.2.7
66

7-
# Only for tools/mkicon.py (pack.sh's app icon)
7+
# Only for pack.sh's app icon (tools/exeicon.py, tools/mkicon.py)
88
Pillow>=10

‎tools/exeicon.py‎

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
#!/usr/bin/env python3
2+
"""Build the app icon (.icns) from SPEED2.EXE's own icon resource.
3+
4+
The exe carries one icon group, IDI_ICON1, with a single 32x32 24-bit image
5+
(the grey NFS logo; the NFSU_icon.ico next to it is the same picture). The
6+
logo is white on grey, every other shade is anti-aliasing, so it is traced
7+
back to vectors: the edge is taken where a smooth enlargement of the
8+
coverage crosses 50 %, fitted with straight lines, and the lines are snapped
9+
to the logo's own angles (level and its italic slant) so the corners come
10+
out sharp. Other icons are enlarged in nearest-neighbour steps. The result
11+
is cut to the macOS icon shape (an 824 px rounded tile on the 1024 grid,
12+
with a soft shadow) and written in every size iconutil wants. For personal
13+
use: the artwork is EA's.
14+
15+
Usage: tools/exeicon.py <SPEED2.EXE> <out.icns> [<preview.png>]
16+
"""
17+
import io, math, os, struct, subprocess, sys, tempfile
18+
import pefile
19+
from PIL import Image, ImageChops, ImageDraw, ImageFilter
20+
21+
RT_ICON, RT_GROUP_ICON = 3, 14
22+
CANVAS, TILE, RADIUS = 1024, 824, 185 # Big Sur+ icon grid
23+
24+
25+
def exe_icons(path):
26+
"""All images of the exe's first icon group, as (w, h, bpp, bytes)."""
27+
pe = pefile.PE(path, fast_load=True)
28+
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY['IMAGE_DIRECTORY_ENTRY_RESOURCE']])
29+
icons, group = {}, None
30+
for t in pe.DIRECTORY_ENTRY_RESOURCE.entries:
31+
for e in t.directory.entries:
32+
for lang in e.directory.entries:
33+
d = lang.data.struct
34+
raw = pe.get_data(d.OffsetToData, d.Size)
35+
if t.id == RT_ICON:
36+
icons[e.id] = raw
37+
elif t.id == RT_GROUP_ICON and group is None:
38+
group = raw
39+
if group is None:
40+
sys.exit("exeicon: no icon group in %s" % path)
41+
_, _, count = struct.unpack_from('<HHH', group)
42+
out = []
43+
for i in range(count):
44+
w, h, _, _, _, bpp, size, iid = struct.unpack_from('<BBBBHHIH', group, 6 + 14 * i)
45+
if iid in icons:
46+
out.append((w or 256, h or 256, bpp, icons[iid]))
47+
return out
48+
49+
50+
def decode(w, h, bpp, raw):
51+
"""One RT_ICON image (PNG or BMP DIB with AND mask) -> RGBA."""
52+
if raw[:8] == b'\x89PNG\r\n\x1a\n':
53+
return Image.open(io.BytesIO(raw)).convert('RGBA')
54+
# Wrap the DIB in a one-image .ico and let Pillow apply the AND mask.
55+
ico = struct.pack('<HHH', 0, 1, 1) + struct.pack('<BBBBHHII', w & 255, h & 255, 0, 0, 1, bpp,
56+
len(raw), 22) + raw
57+
im = Image.open(io.BytesIO(ico))
58+
im.size = (w, h)
59+
return im.convert('RGBA')
60+
61+
62+
def pixels(im):
63+
p = im.convert('RGB').load()
64+
return [p[x, y] for y in range(im.height) for x in range(im.width)]
65+
66+
67+
def two_tone(art):
68+
"""(background, foreground) if art is one colour on another plus blends of the two."""
69+
px = pixels(art)
70+
bg = max(set(px), key=px.count)
71+
fg = max(px, key=lambda c: sum((a - b) ** 2 for a, b in zip(c, bg)))
72+
span = sum((a - b) ** 2 for a, b in zip(fg, bg))
73+
if span < 64 ** 2:
74+
return None
75+
for c in set(px):
76+
t = sum((a - b) * (f - b) for a, b, f in zip(c, bg, fg)) / span
77+
if sum((a - (b + t * (f - b))) ** 2 for a, b, f in zip(c, bg, fg)) > 12 ** 2:
78+
return None
79+
return bg, fg
80+
81+
82+
def contours(f, w, h):
83+
"""Marching squares at 0.5 on f[y][x] (0 outside): closed loops of points."""
84+
def v(x, y):
85+
return f[y][x] if 0 <= x < w and 0 <= y < h else 0.0
86+
87+
def pt(e):
88+
k, x, y = e
89+
a, b = (v(x, y), v(x + 1, y)) if k == 'h' else (v(x, y), v(x, y + 1))
90+
t = (0.5 - a) / (b - a)
91+
return (x + t, y) if k == 'h' else (x, y + t)
92+
adj = {}
93+
94+
def link(p, q):
95+
adj.setdefault(p, []).append(q)
96+
adj.setdefault(q, []).append(p)
97+
for y in range(-1, h):
98+
for x in range(-1, w):
99+
a, b, c, d = v(x, y), v(x + 1, y), v(x + 1, y + 1), v(x, y + 1)
100+
T, R, B, L = ('h', x, y), ('v', x + 1, y), ('h', x, y + 1), ('v', x, y)
101+
s = (a >= .5) | (b >= .5) << 1 | (c >= .5) << 2 | (d >= .5) << 3
102+
if s in (0, 15):
103+
continue
104+
if s in (5, 10): # saddle: the centre decides
105+
if (s == 5) == ((a + b + c + d) / 4 >= .5):
106+
link(T, R); link(B, L)
107+
else:
108+
link(T, L); link(B, R)
109+
continue
110+
es = [e for e, p, q in ((T, a, b), (R, b, c), (B, d, c), (L, a, d)) if (p >= .5) != (q >= .5)]
111+
link(es[0], es[1])
112+
loops, seen = [], set()
113+
for e in adj:
114+
loop, prev = [], None
115+
while e not in seen:
116+
seen.add(e)
117+
loop.append(pt(e))
118+
nxt = [q for q in adj[e] if q != prev and q not in seen] or [q for q in adj[e] if q != prev]
119+
prev, e = e, nxt[0]
120+
if loop:
121+
loops.append(loop)
122+
return loops
123+
124+
125+
def corners(pts, a, b, tol, out):
126+
"""Douglas-Peucker: indices in pts[a:b] where the outline turns."""
127+
(x0, y0), (x1, y1) = pts[a], pts[b]
128+
dx, dy = x1 - x0, y1 - y0
129+
n = math.hypot(dx, dy) or 1e-9
130+
i, dm = a, -1.0
131+
for k in range(a + 1, b):
132+
d = abs(dy * (pts[k][0] - x0) - dx * (pts[k][1] - y0)) / n
133+
if d > dm:
134+
i, dm = k, d
135+
if dm > tol:
136+
corners(pts, a, i, tol, out)
137+
corners(pts, i, b, tol, out)
138+
else:
139+
out.append(a)
140+
141+
142+
def fit(pts):
143+
"""Least-squares line through pts: [centre, angle, length]."""
144+
n = len(pts)
145+
cx, cy = sum(p[0] for p in pts) / n, sum(p[1] for p in pts) / n
146+
sxx = sum((p[0] - cx) ** 2 for p in pts)
147+
syy = sum((p[1] - cy) ** 2 for p in pts)
148+
sxy = sum((p[0] - cx) * (p[1] - cy) for p in pts)
149+
return [(cx, cy), 0.5 * math.atan2(2 * sxy, sxx - syy), math.dist(pts[0], pts[-1])]
150+
151+
152+
def meet(l1, l2):
153+
(x1, y1), a1, _ = l1
154+
(x2, y2), a2, _ = l2
155+
den = math.cos(a1) * math.sin(a2) - math.sin(a1) * math.cos(a2)
156+
if abs(den) < 0.05:
157+
return None
158+
t = ((x2 - x1) * math.sin(a2) - (y2 - y1) * math.cos(a2)) / den
159+
return (x1 + t * math.cos(a1), y1 + t * math.sin(a1))
160+
161+
162+
def trace(art, bg, fg, up=8, tol=0.25, snap=10):
163+
"""The foreground of a two-tone image as polygons, in source pixels."""
164+
w, h = art.size
165+
lum = lambda c: sum(c[:3])
166+
lb, lf = lum(bg), lum(fg)
167+
cov = Image.new('L', art.size)
168+
cov.putdata([max(0, min(255, round((lum(c) - lb) * 255 / (lf - lb)))) for c in pixels(art)])
169+
px = cov.resize((w * up, h * up), Image.BICUBIC).load()
170+
f = [[px[x, y] / 255 for x in range(w * up)] for y in range(h * up)]
171+
outlines = []
172+
for loop in contours(f, w * up, h * up):
173+
if len(loop) < 8:
174+
continue
175+
pts = [((x + .5) / up, (y + .5) / up) for x, y in loop]
176+
n = len(pts)
177+
far = max(range(n), key=lambda k: math.dist(pts[k], pts[0]))
178+
ring, idx = pts + [pts[0]], []
179+
corners(ring, 0, far, tol, idx)
180+
corners(ring, far, n, tol, idx)
181+
outlines.append([fit(ring[a:b + 1]) for a, b in zip(idx, idx[1:] + [n])])
182+
# The logo's own directions: level, plus the strongest angles by edge length.
183+
hist = [0.0] * 180
184+
for lines in outlines:
185+
for l in lines:
186+
hist[round(math.degrees(l[1])) % 180] += l[2]
187+
hist = [sum(hist[(i + k) % 180] for k in range(-2, 3)) for i in range(180)]
188+
gap = lambda a, b: min(abs(a - b) % 180, 180 - abs(a - b) % 180)
189+
dirs = [0]
190+
for i in sorted(range(180), key=lambda i: -hist[i]):
191+
if hist[i] < 0.08 * max(hist):
192+
break
193+
if all(gap(i, d) > snap for d in dirs):
194+
dirs.append(i)
195+
polys = []
196+
for lines in outlines:
197+
for l in lines:
198+
deg = math.degrees(l[1]) % 180
199+
d = min(dirs, key=lambda d: gap(deg, d))
200+
if gap(deg, d) <= snap:
201+
l[1] = math.radians(d)
202+
# merge neighbours left on the same line, drop slivers
203+
off = lambda l: -math.sin(l[1]) * l[0][0] + math.cos(l[1]) * l[0][1]
204+
k = 0
205+
while k < len(lines) and len(lines) > 3:
206+
a, b = lines[k - 1], lines[k]
207+
if abs(a[1] - b[1]) < 1e-6 and abs(off(a) - off(b)) < 0.3:
208+
s = a[2] + b[2]
209+
a[0] = ((a[0][0] * a[2] + b[0][0] * b[2]) / s, (a[0][1] * a[2] + b[0][1] * b[2]) / s)
210+
a[2] = s
211+
elif b[2] >= 0.35:
212+
k += 1
213+
continue
214+
del lines[k]
215+
k = 0
216+
polys.append([meet(lines[k - 1], lines[k]) or lines[k][0] for k in range(len(lines))])
217+
return polys
218+
219+
220+
def draw(polys, size, bg, fg, fill=0.8, ss=4):
221+
"""Polygons (even-odd) filled with fg on bg, anti-aliased, centred to fill
222+
that share of the width (the icon's art runs into its own left edge)."""
223+
xs, ys = [x for p in polys for x, _ in p], [y for p in polys for _, y in p]
224+
k = size * ss * fill / (max(xs) - min(xs))
225+
ox = size * ss / 2 - k * (min(xs) + max(xs)) / 2
226+
oy = size * ss / 2 - k * (min(ys) + max(ys)) / 2
227+
m = Image.new('1', (size * ss, size * ss), 0)
228+
for p in polys:
229+
one = Image.new('1', m.size, 0)
230+
ImageDraw.Draw(one).polygon([(ox + x * k, oy + y * k) for x, y in p], fill=1)
231+
m = ImageChops.logical_xor(m, one)
232+
m = m.convert('L').resize((size, size), Image.BOX)
233+
return Image.composite(Image.new('RGBA', m.size, fg + (255,)), Image.new('RGBA', m.size, bg + (255,)), m)
234+
235+
236+
def master(art):
237+
"""The 1024x1024 icon: art enlarged to the tile, rounded, with a shadow."""
238+
tones = two_tone(art) if art.getextrema()[3][0] == 255 and art.width == art.height else None
239+
if tones:
240+
tile = draw(trace(art, *tones), TILE, *tones)
241+
else:
242+
k = -(-TILE // art.width) # integer factor >= tile
243+
big = art.resize((art.width * k, art.height * k), Image.NEAREST)
244+
tile = big.resize((TILE, TILE), Image.LANCZOS)
245+
mask = Image.new('L', (TILE * 4, TILE * 4), 0)
246+
ImageDraw.Draw(mask).rounded_rectangle([0, 0, TILE * 4 - 1, TILE * 4 - 1], radius=RADIUS * 4, fill=255)
247+
mask = mask.resize((TILE, TILE), Image.LANCZOS)
248+
tile.putalpha(Image.composite(tile.getchannel('A'), Image.new('L', tile.size, 0), mask))
249+
250+
off = (CANVAS - TILE) // 2
251+
shadow = Image.new('L', (CANVAS, CANVAS), 0)
252+
shadow.paste(mask.point(lambda v: v * 0.45), (off, off + 12))
253+
shadow = shadow.filter(ImageFilter.GaussianBlur(14))
254+
img = Image.new('RGBA', (CANVAS, CANVAS), (0, 0, 0, 0))
255+
img.putalpha(shadow)
256+
img.alpha_composite(tile, (off, off))
257+
return img
258+
259+
260+
def main():
261+
if len(sys.argv) < 3:
262+
sys.exit(__doc__)
263+
exe, out = sys.argv[1], sys.argv[2]
264+
imgs = exe_icons(exe)
265+
w, h, bpp, raw = max(imgs, key=lambda t: (t[0] * t[1], t[2]))
266+
art = decode(w, h, bpp, raw)
267+
img = master(art)
268+
if len(sys.argv) > 3:
269+
img.save(sys.argv[3])
270+
with tempfile.TemporaryDirectory() as td:
271+
iconset = os.path.join(td, 'AppIcon.iconset')
272+
os.mkdir(iconset)
273+
for px in (16, 32, 128, 256, 512):
274+
img.resize((px, px), Image.LANCZOS).save(os.path.join(iconset, 'icon_%dx%d.png' % (px, px)))
275+
img.resize((px * 2, px * 2), Image.LANCZOS).save(os.path.join(iconset, 'icon_%dx%[email protected]' % (px, px)))
276+
subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', out], check=True)
277+
print('icon: %s (from %s: %dx%d, %d-bit, IDI_ICON1)' % (out, os.path.basename(exe), w, h, bpp))
278+
279+
280+
if __name__ == '__main__':
281+
main()

0 commit comments

Comments
 (0)