exp-runtime-shs 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Surviving High School native game contracts."""
@@ -0,0 +1,33 @@
1
+ """Android 1.0.9 music cues from SHS09SoundEngine.playMusic in classes.dex.
2
+
3
+ The inspected iOS SHSSoundManager constructor uses the same cue table
4
+ (00074d74..000754ac); see docs/IPA.md for source availability limits.
5
+ Some script IDs name positions within another MP3, not separate assets.
6
+ Resolve them only for music playback; resource lookup and VM IDs stay exact.
7
+ """
8
+ from ..audio import MusicCue
9
+
10
+
11
+ # SHSEngine::resetResList (iOS 0003a3c8): these original music IDs were
12
+ # downloaded outside the IPA. Their Android 1.0.9 counterparts share the
13
+ # same IDs/cues. No other cross-platform resource equivalence is implied.
14
+ IOS_DOWNLOADED_MUSIC = (8201, 8205, 8207, 8209, 8212, 8217, 8219, 8221, 8223, 8224)
15
+
16
+
17
+ _MUSIC_REDIRECTS = {
18
+ 8202: MusicCue(8201, 2800),
19
+ 8204: MusicCue(8203, 720),
20
+ 8206: MusicCue(8205, 28280),
21
+ 8208: MusicCue(8207, 3270),
22
+ 8211: MusicCue(8210, 6000),
23
+ 8213: MusicCue(8212, 4000),
24
+ 8214: MusicCue(8212, 39200),
25
+ 8216: MusicCue(8215, 1320),
26
+ 8218: MusicCue(8217, 1920),
27
+ 8220: MusicCue(8219, 10600),
28
+ 8225: MusicCue(8224, 29200),
29
+ }
30
+
31
+
32
+ def music_cue(resource_id):
33
+ return _MUSIC_REDIRECTS.get(resource_id, MusicCue(resource_id))
@@ -0,0 +1,50 @@
1
+ """Extract the base game's built-in story into an ordinary local CSPUD episode.
2
+
3
+ Only resource IDs and container structure live in the engine. Story text,
4
+ bytecode and localized titles are copied from the player's validated APK/IPA.
5
+ """
6
+ import struct
7
+
8
+ from ..content import ContentError, ExpArchive, _zip_read
9
+
10
+
11
+ FOOTBALL_SCRIPTS = (*range(25001, 25022), 25023)
12
+ FOOTBALL_SOURCE = 'football-star'
13
+ FOOTBALL_NAME = 'Football_Star.exp'
14
+ FOOTBALL_ALIASES = ['Football Season', 'Football_Season.exp', 'footballseason', 'footballstar']
15
+
16
+
17
+ def extract_football(apk, asset_root='assets/Assets/', *, ios=False):
18
+ """Return deterministic EXP bytes, or None when no base story is present."""
19
+ if asset_root + '25001' not in apk.namelist():
20
+ return None
21
+ # Imported lazily: menu strings share the asset parser, not a pygame UI.
22
+ from ..menu import MenuStrings
23
+ titles = MenuStrings.parse(_zip_read(apk, asset_root + ('12' if ios else '13')))
24
+ # Native built-in episode key is zero; FUN_0009794c packs (pack, episode).
25
+ metadata = bytearray(struct.pack('>HH', 0, 0))
26
+ for index in range(193, 198):
27
+ title = titles[index].encode('utf-8')
28
+ if not title or len(title) > 65535:
29
+ raise ContentError('Invalid built-in episode title')
30
+ metadata += struct.pack('>H', len(title)) + title
31
+ records = {1: bytes(metadata)}
32
+ for resource_id in range(25001, 25022) if ios else FOOTBALL_SCRIPTS:
33
+ data = _zip_read(apk, f'{asset_root}{resource_id}')
34
+ if not data.startswith(b'kiwi'):
35
+ raise ContentError(f'Built-in story resource {resource_id} is not KiWi')
36
+ records[resource_id] = data
37
+ # Literal records preserve every byte, address and script ID. Art/audio
38
+ # keep their native base namespace, just as they do for external episodes.
39
+ index, bodies = bytearray(), bytearray()
40
+ start = 9 + 6 * len(records)
41
+ for resource_id, data in sorted(records.items()):
42
+ index += struct.pack('>HI', resource_id, start + len(bodies))
43
+ bodies += struct.pack('>III', len(data), len(data), 0) + data
44
+ result = b'CSPUD' + struct.pack('>I', len(records)) + index + bodies
45
+ ExpArchive(result).programs() # Validate every copied script before publishing.
46
+ return bytes(result)
47
+
48
+
49
+ def football_source():
50
+ return dict(builtin=FOOTBALL_SOURCE, aliases=FOOTBALL_ALIASES[:])
@@ -0,0 +1,25 @@
1
+ """Validation of the supported Surviving High School Android package."""
2
+ from zipfile import ZipFile
3
+
4
+ NATIVE_MEMBER = 'lib/armeabi/libshs09.so'
5
+ NATIVE_SHA256 = 'b17aa4c71bc46666d414cafae6fac92bbcd755f3dcccd73975cf05f4a119665b'
6
+ BUILTIN = 'football-star'
7
+
8
+
9
+ def extract_builtin(package, asset_root, *, ios):
10
+ from .builtin_episode import FOOTBALL_NAME, extract_football, football_source
11
+ data = extract_football(package, asset_root, ios=ios)
12
+ return None if data is None else (data, FOOTBALL_NAME, football_source())
13
+
14
+
15
+ def _inspect_apk(apk: ZipFile):
16
+ from ..content import ContentError, digest, _zip_read
17
+ names = apk.namelist()
18
+ if len(names) != len(set(names)):
19
+ raise ContentError('APK contains ambiguous duplicate member names')
20
+ actual = digest(_zip_read(apk, NATIVE_MEMBER))
21
+ if actual != NATIVE_SHA256:
22
+ raise ContentError(f'Unsupported game APK native library ({actual}); '
23
+ 'the currently supported profile is SHS Android 1.0.9')
24
+ return actual
25
+
@@ -0,0 +1,319 @@
1
+ """Native service-94 art, fonts, panels, play targets and feedback."""
2
+ from dataclasses import replace
3
+ from functools import lru_cache
4
+ from io import BytesIO
5
+ import math
6
+
7
+ from .. import graphics as pygame
8
+ from ..desktop_pause import draw_pause_gear
9
+
10
+ from ..atlas import AtlasFont, SpriteAtlas
11
+ from .football import PLAY_FRAMES, TARGET_CENTERS
12
+ from .football_layout import (FONT, HELP_STYLE, LABEL_STYLE, TITLE_STYLE, clamp,
13
+ countdown_motion, feedback_motion, feedback_text,
14
+ footer_id, heading_id, help_motion, legend_codes,
15
+ target_position, team_name, yard_glyphs, score_strip)
16
+ from ..menu import MenuStrings
17
+ from ..ui_assets import ImagePack, Raster, read_ui
18
+
19
+
20
+ class FootballRenderer:
21
+ def __init__(self, resources, text, art):
22
+ self.resources, self.text, self.art = resources, text, art
23
+ self.canvas = pygame.Surface((320, 480)).convert(32)
24
+
25
+ @lru_cache(maxsize=1)
26
+ def atlas(self):
27
+ return SpriteAtlas.parse(read_ui(self.resources, 290))
28
+
29
+ @lru_cache(maxsize=154)
30
+ def frame(self, index):
31
+ r = self.atlas().raster(index)
32
+ return pygame.image.frombytes(r.pixels, (r.width, r.height), 'RGBA').convert_alpha()
33
+
34
+ @lru_cache(maxsize=10)
35
+ def image(self, asset):
36
+ data = read_ui(self.resources, asset)
37
+ if data.startswith((b'\x89PNG', b'\xff\xd8')):
38
+ return pygame.image.load(BytesIO(data)).convert_alpha()
39
+ raster = ImagePack.parse(data).images[0]
40
+ return pygame.image.frombytes(raster.pixels, (raster.width, raster.height), raster.mode).convert_alpha()
41
+
42
+ @lru_cache(maxsize=1)
43
+ def strings(self):
44
+ return MenuStrings.load(self.resources)
45
+
46
+ @lru_cache(maxsize=1)
47
+ def feedback_font(self):
48
+ # Unlike grid glyph sheets, 540 is a PNG; 541 uses the CS metrics.
49
+ image = self.image(540)
50
+ return AtlasFont.parse(read_ui(self.resources, 541),
51
+ Raster(*image.get_size(), pygame.image.tobytes(image, 'RGBA')))
52
+
53
+ @lru_cache(maxsize=128)
54
+ def feedback_image(self, text):
55
+ font, atlas = self.feedback_font(), self.image(540)
56
+ lines = font.wrap(text, 300)
57
+ width = max((font.width(line) for line in lines), default=0)
58
+ image = pygame.Surface((max(1, width), max(1, font.text_height(lines))), pygame.SRCALPHA)
59
+ for row, line in enumerate(lines):
60
+ x = (width - font.width(line)) // 2
61
+ for char in line:
62
+ glyph = font.glyph(char)
63
+ if glyph is not None and char != ' ':
64
+ gx, gy, gw = glyph
65
+ image.blit(atlas, (x, row * (font.height + font.line_gap)), (gx, gy, gw, font.height))
66
+ x += font.char_width(char) + font.tracking
67
+ return image
68
+
69
+ @lru_cache(maxsize=1)
70
+ def score_font(self):
71
+ # loadImageFont(523,522) at SHS iOS 00088094. Both supplied asset
72
+ # profiles contain the same CS glyph sheet in semantic roles 524/525.
73
+ raster = SpriteAtlas.parse(read_ui(self.resources, 524)).raster(0)
74
+ return (AtlasFont.parse(read_ui(self.resources, 525), raster),
75
+ pygame.image.frombytes(raster.pixels, (raster.width, raster.height), 'RGBA').convert_alpha())
76
+
77
+ @lru_cache(maxsize=64)
78
+ def score_text(self, text):
79
+ font, atlas = self.score_font()
80
+ image = pygame.Surface((max(1, font.width(text)), font.height), pygame.SRCALPHA)
81
+ x = 0
82
+ for char in text:
83
+ glyph = font.glyph(char)
84
+ if glyph is not None and char != ' ':
85
+ gx, gy, gw = glyph
86
+ image.blit(atlas, (x, 0), (gx, gy, gw, font.height))
87
+ x += font.char_width(char) + font.tracking
88
+ return image
89
+
90
+ def scoreboard(self, game):
91
+ font, _ = self.score_font()
92
+ home, away = (team_name(self.strings(), selector) for selector in game.teams)
93
+ frame, offset = score_strip(font.width(home), font.width(away))
94
+ literals = self.atlas().literals(frame)
95
+ center = 253 + min(x for _, x, _ in literals) + offset
96
+ self.composite(frame, 253, 50)
97
+ for text, x, width in ((home, center - 28 - font.width(home), 0),
98
+ (str(game.home), center - 24, 17), ('-', center - 3, 6),
99
+ (str(game.away), center + 7, 17), (away, center + 28, 0)):
100
+ image = self.score_text(text)
101
+ self.canvas.blit(image, (x + int((width - font.width(text)) / 2) if width else x, 42))
102
+
103
+ def blit(self, image, x, y, *, scale=1., scale_y=None, alpha=1., angle=0., center=False):
104
+ scale_y = scale if scale_y is None else scale_y
105
+ if scale <= 0 or scale_y <= 0 or alpha <= 0:
106
+ return
107
+ if scale != 1 or scale_y != 1:
108
+ image = pygame.transform.smoothscale(image, (max(1, round(image.get_width() * scale)),
109
+ max(1, round(image.get_height() * scale_y))))
110
+ if angle:
111
+ image = pygame.transform.rotate(image, -angle)
112
+ if alpha < 1:
113
+ image = image.copy()
114
+ image.set_alpha(round(255 * clamp(alpha)))
115
+ self.canvas.blit(image, image.get_rect(center=(round(x), round(y))) if center else (round(x), round(y)))
116
+
117
+ def node(self, index, x, y, **transform):
118
+ """0009936c positions the returned sprite at x+w/2, y+h/2.
119
+
120
+ Play and glyph composites each contain one literal. Their serialized
121
+ negative origin is replaced by this node placement; applying it again
122
+ would shift the art out of its fixed touch box.
123
+ """
124
+ literals = self.atlas().literals(index)
125
+ if len(literals) != 1:
126
+ raise ValueError('Football node requires a single-literal sprite')
127
+ image = self.frame(literals[0][0])
128
+ self.blit(image, x + image.get_width() / 2, y + image.get_height() / 2,
129
+ center=True, **transform)
130
+
131
+ def composite(self, index, x, y):
132
+ # Field chalk uses the direct composite path, retaining child offsets.
133
+ for ref, dx, dy in self.atlas().literals(index):
134
+ self.blit(self.frame(ref), x + dx, y + dy)
135
+
136
+ def label(self, text, x, y, *, width=320, style=LABEL_STYLE, center=False):
137
+ """Native line-box origin, with per-line horizontal centering."""
138
+ layout = self.text.layout(FONT, text, width, style)
139
+ if center:
140
+ offsets = {line.start: -line.width / 2 for line in layout.lines}
141
+ glyphs = tuple(replace(g, x=g.x + offsets[line.start]) for line in layout.lines
142
+ for g in layout.glyphs if line.start <= g.index < line.end)
143
+ layout = replace(layout, glyphs=glyphs)
144
+ self.text.draw_layout(self.canvas, FONT, layout, x, y)
145
+
146
+ @staticmethod
147
+ def hit(position):
148
+ # 000bc2ec: inclusive fixed bounds, independent of target zoom.
149
+ px, py = position
150
+ return next((i for i, (x, y) in enumerate(TARGET_CENTERS)
151
+ if x - 35 <= px <= x + 35 and y - 53 <= py <= y + 17), None)
152
+
153
+ def field(self, game):
154
+ # 000b8410 / 000b82f4: field camera, turf, authored chalk and haze.
155
+ position = game.camera_position
156
+ y = -position * 45
157
+ x = (y * -.40613848) % 320
158
+ y = -((-y) % 480)
159
+ for dx, dy in ((0, 0), (-320, 0), (0, 480), (-320, 480)):
160
+ self.blit(self.frame(86), x + dx, y + dy)
161
+ frames = (-83, -65, -69, -73, -77, -79, -76, -72, -68, -64, -84)
162
+ start = int(position) - 6
163
+ for yard in range(start, start + 14):
164
+ if 0 <= yard <= 100 and yard % 5 == 0:
165
+ y = 240 + (yard - position) * 45
166
+ self.composite(frames[yard // 10] if yard % 10 == 0 else -80,
167
+ int(y * -.4354067), int(y - 56))
168
+ x, y = -(int(game.visual_ms * .02) % 320), int(game.visual_ms * .05) % 480
169
+ for dx, dy in ((0, 0), (0, -480), (320, 0), (320, -480)):
170
+ self.blit(self.frame(75), x + dx, y + dy, scale=4, alpha=.14705883)
171
+
172
+ def hud_digits(self, text, x, y, width=0, *, clock=False):
173
+ """000b6818 / 000b68b4 / 000b9e90: measured and drawn spacing differ."""
174
+ indices = [128 + int(c) if c.isdigit() else {'-': 138, '+': 139, ':': 150}[c] for c in text]
175
+ measure = sum(self.frame(i).get_width() for i in indices) + len(indices) - 1
176
+ x += int((width - measure) / 2)
177
+ for char, index in zip(text, indices):
178
+ image = self.frame(index)
179
+ w, h = image.get_size()
180
+ if clock:
181
+ height = 4 if char == ':' else h
182
+ native_y = int(480 - y - height + h * 1.5)
183
+ native_y -= native_y % 2
184
+ self.blit(image, x - 1 - w / 2, 480 - (native_y - 5) - h)
185
+ x += w + {'1': 2, ':': 4}.get(char, 0)
186
+ else:
187
+ height = {'-': 6, '+': 7, ':': 8}.get(char, h)
188
+ self.blit(image, x + 2 - w / 2, y + height - 2 * h)
189
+ x += w + 3
190
+
191
+ def hud(self, game):
192
+ strings = self.strings()
193
+ self.node(30, 10, 13)
194
+ self.node(153 if game.defense else 152, 19, 4)
195
+ # BM labels draw downward from local zero; center alignment adds
196
+ # half the measured line height (14), unlike ordinary sprite anchors.
197
+ self.label(strings[heading_id(game)], 109, 7, center=True)
198
+ self.blit(self.image(701 + min(game.down, 3)), 99, 33, center=True)
199
+ distance = int(100 - game.hud_position if game.defense else game.hud_position)
200
+ self.hud_digits(str(max(0, min(100, distance))), 186, 37, 20)
201
+ seconds = max(0, math.ceil(game.remaining_ms / 1000))
202
+ self.hud_digits(f'{seconds // 60}:{seconds % 60:02}', 165, 37, clock=True)
203
+ self.scoreboard(game)
204
+
205
+ def targets(self, game):
206
+ for target, (x, y) in zip(game.targets, TARGET_CENTERS):
207
+ if not target.visible:
208
+ continue
209
+ scale = target.scale
210
+ if target.phase == 2:
211
+ for growth, alpha in ((scale, 1 - .8 * scale), (scale + .2, 1 - 1.25 * scale)):
212
+ # Native changes the ripple anchor to top-left after
213
+ # setting a center position; its sheet is half-height.
214
+ self.blit(self.frame(127), x - 2 - growth * 33, y - 7 - growth * 16.5,
215
+ scale=growth, scale_y=growth * .5, alpha=alpha)
216
+ if target.phase in (3, 6):
217
+ alpha = math.sin(game.visual_ms / 1000 * 4) * .3 + .7
218
+ if getattr(self.resources.library, 'kind', 'apk') == 'ipa':
219
+ # ItemSlot::drawSelectionRing (0008efa8): the iOS asset is
220
+ # frame 125, rotating at 360 degrees/s then halved in Y.
221
+ shadow = pygame.transform.rotate(self.frame(125), -(game.visual_ms * .36 % 360))
222
+ self.blit(shadow, x, y, scale_y=.5, alpha=alpha, center=True)
223
+ else:
224
+ # 000c0ac0: 110ms per exported Android shadow frame.
225
+ shadow = self.image(720 + (target.elapsed_ms // 110) % 4)
226
+ self.blit(shadow, x, 600 - y, scale=1.3, scale_y=1.2, alpha=alpha / 1.5, center=True)
227
+ elif target.phase == 7 and target.elapsed_ms >= 100:
228
+ progress = (target.elapsed_ms - 100) / 500
229
+ for growth in (progress, min(1., 1.3 - progress), min(1., 1.5 - progress)):
230
+ self.blit(self.frame(126), x - 2 - growth * 33, y - 7 - growth * 16.5,
231
+ scale=growth, scale_y=growth * .5, alpha=1 - progress)
232
+ self.node(PLAY_FRAMES[target.code], *target_position(target, (x, y)), scale=scale, alpha=scale)
233
+ if abs(target.code) == 4:
234
+ self.node(-1, x, y - 38, scale=scale, alpha=scale)
235
+ elif abs(target.code) != 5:
236
+ for frame, gx, gy in yard_glyphs(target.yards, x, y - 38):
237
+ self.node(frame, gx, gy, scale=scale, alpha=scale)
238
+
239
+ def effects(self, game):
240
+ """000b2494 / 000b4bd0: selected play squashes and spins away."""
241
+ for effect in game.effects:
242
+ remaining = effect.remaining_ms
243
+ if effect.selected:
244
+ if remaining >= 1000:
245
+ r = (remaining - 1000) / 500
246
+ sx, sy, alpha, angle = 1 + (1 - r) * 3, .2 + r * .8, 1., 1.
247
+ else:
248
+ r = remaining / 1000
249
+ sx, sy, alpha, angle = r * 4, .2 - r * .2, r, r * 180
250
+ _, dx, dy = self.atlas().literals(effect.frame)[0]
251
+ self.node(effect.frame, effect.x + dx, effect.y + dy,
252
+ scale=sx, scale_y=sy, alpha=alpha, angle=angle)
253
+ else:
254
+ r = remaining / 1300
255
+ scale = r * effect.scale
256
+ self.node(effect.frame, effect.x, effect.y - 18 * scale,
257
+ scale=scale, alpha=r)
258
+
259
+ def help(self, game):
260
+ strings = self.strings()
261
+ coach = self.frame(game.field_variant)
262
+ m = help_motion(game, coach.get_width())
263
+ self.node(64, m.x, m.y, scale=m.scale, alpha=m.alpha)
264
+ if m.alpha > 0:
265
+ self.node(153 if game.defense else 152, m.x + 6, m.y - 9,
266
+ scale=m.scale, alpha=m.alpha)
267
+ # BM labels follow the moving panel, but do not inherit its scale.
268
+ self.label(strings[77 if game.defense else 76], m.x + 97, m.y - 6,
269
+ width=166, style=TITLE_STYLE, center=True)
270
+ self.label(game.instruction, m.x + 25, m.y + 26, width=256, style=HELP_STYLE)
271
+ self.label(strings[166], m.x + 18, m.y + 183)
272
+ best = self.text.layout(FONT, strings[165], 320, LABEL_STYLE)
273
+ self.label(strings[165], m.x + 289 - best.width, m.y + 183)
274
+ codes = legend_codes(game)
275
+ spacing = 217 // (len(codes) - 1) if len(codes) > 1 else 0
276
+ for i, code in enumerate(codes):
277
+ frame = self.frame(self.atlas().literals(PLAY_FRAMES[code])[0][0])
278
+ x = m.x + 45 + i * spacing - 35 + frame.get_width() / 2
279
+ # 000be110 sets anchor (.5,0) after node placement.
280
+ bottom = m.y + 141 + frame.get_height() / 2
281
+ s = m.scale / 1.1
282
+ self.blit(frame, x - frame.get_width() * s / 2, bottom - frame.get_height() * s,
283
+ scale=s, alpha=m.alpha)
284
+ self.blit(coach, m.coach_x, 457 - coach.get_height(), alpha=m.coach_alpha)
285
+
286
+ def feedback(self, game):
287
+ motion = feedback_motion(game)
288
+ if motion is None:
289
+ return
290
+ self.node(66, motion.x, 240)
291
+ if motion.shine_x is not None:
292
+ self.node(65, motion.shine_x, 240)
293
+ if motion.visible:
294
+ image = self.feedback_image(feedback_text(game, self.strings(), motion.index))
295
+ height = self.feedback_font().height
296
+ y = 259 - (height >> 2) if image.get_height() <= height else 259
297
+ self.blit(image, 160, y, scale=motion.scale, angle=motion.angle, center=True)
298
+
299
+ def draw(self, target, session):
300
+ game = session.engine.football
301
+ self.canvas.fill((0, 0, 0))
302
+ self.field(game)
303
+ self.targets(game)
304
+ self.effects(game)
305
+ self.hud(game)
306
+ self.feedback(game)
307
+ if game.phase in (20, 21, 22, 23, 25):
308
+ self.help(game)
309
+ elif game.phase == 0:
310
+ motion = countdown_motion(game.phase_ms)
311
+ self.blit(self.frame(motion.frame), 159.5, 240, scale=motion.scale,
312
+ alpha=motion.alpha, angle=motion.angle, center=True)
313
+ self.canvas.blit(self.art.frame(126, 47), (0, 431))
314
+ draw_pause_gear(self.canvas, self.art)
315
+ footer = footer_id(game)
316
+ if footer is not None:
317
+ self.label(self.strings()[footer], 180, 462, center=True)
318
+ target.blit(pygame.transform.smoothscale(self.canvas, target.get_size()), (0, 0))
319
+ return [(pygame.Rect(0, 422, 60, 58), ('menu',))]